detective Peter Seibel Practical Common Lisp Апрель 2005 pcl.lisp 2010-03-15 1.0

2010-03-15 Исходная версия

1
<p>1. Введение: почему Lisp?</p>

Если вы считаете, что величайшее удовольствие в программировании доставляет код, делающий многое и выражающий ваши желания просто и ясно, тогда программирование на Common Lisp вероятно будет самым приятным из того, что можно сделать на компьютере. С Common Lisp вы сделаете больше и быстрее, чем с большинством других языков программирования.

Серьезное заявление. Могу ли я доказать это? Да, но не на нескольких страницах введения. Вам придётся познакомиться с Lisp поближе и убедиться в этом самим — поэтому читайте книгу до конца. А сейчас позвольте мне начать с нескольких смешных эпизодов из истории моего пути к языку Lisp. В следующей главе я объясню выгоды, которые вы получите от изучения Common Lisp.

Я один из немногих Lisp-хакеров во втором поколении. Мой отец начал заниматься компьютерами с написания на ассемблере операционной системы для машины, которую он использовал для сбора данных при подготовке его докторской диссертации по физике. После работы с компьютерами в разных физических лабораториях, к 80-м отец полностью оставил физику и стал работать в большой фармацевтической компании. У этой компании был проект по созданию программы, моделирующей производственные процессы на химических заводах (если вы увеличите объем этого резервуара, как это повлияет на годовой результат?). Старая команда писала всё на языке FORTRAN, израсходовала половину бюджета и почти всё отведённое время, но не могла продемонстрировать никаких результатов. Это было в 80-х, пик бума искусственного интеллекта (ИИ), Lisp так и витал в воздухе. Так что мой папа — в то время еще не поклонник языка Lisp — пошёл в университет Карнеги-Меллона, чтобы пообщаться с людьми, работавшими над тем, что впоследствии стало Common Lisp, и узнать, сможет ли Lisp стать подходящим языком для его проекта.

Ребята из университета показали ему кое-что из своих разработок, и это его убедило. Отец, в свою очередь, убедил своих боссов позволить ему взять провальный проект и сделать его на Lisp. Год спустя, используя остатки бюджета, команда отца представила работающее приложение, обладающее возможностями, на реализацию которых старая команда уже и не надеялась. Мой папа объясняет, что причина успеха в решении использовать Lisp.

Однако, это всего лишь первый эпизод. Может быть, мой отец ошибался в причине своего успеха. Или, может быть, Lisp был лучше других языков лишь для того времени. В настоящее время мы имеем кучу новых языков программирования, многие из которых переняли часть достоинств Lisp. Действительно ли я считаю, что использование языка Lisp может дать вам те же выгоды, что и моему отцу в 80-х? Читайте дальше.

Несмотря на все усилия моего отца, я не изучал Lisp в университете. После учёбы, которая не содержала много программирования на каком-либо языке, я был покорен Web и вернулся назад к компьютерам. Сначала я писал на Perl, изучив его достаточно, чтобы создать форум для сайта журнала Mother Jones, после этого я работал над большими (по тем временам) сайтами, такими, как, например, сайт компании Nike, запущенный к олимпийским играм 1996 года. После этого я перешёл на Java, будучи одним из первых разработчиков в WebLogic (теперь эта компания — часть BEA). После WebLogic я участвовал в другом стартапе, где был ведущим программистом по построению транзакционной системы обмена сообщениями на Java. Со временем мои основные интересы в программировании позволили мне использовать как популярные языки, такие, как C, C++ и Python, так и менее известные, такие, как Smalltalk, Eiffel и Beta.

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

Например, в одном из отпусков, имея около недели на опыты с Lisp, я решил попробовать написать версию программы, написанной мною на Java в начале программистской карьеры. Эта программа применяла генетические алгоритмы для игры в Го. Даже с моими зачаточными знаниями Common Lisp написание всего-лишь основных функций было намного продуктивнее, чем если бы я решил переписать всё на Java заново. Для написания программы на Java потребовалось несколько лет работы с этим языком.

Похожий эксперимент привёл к созданию библиотеки, о которой я расскажу в главе 24. В начале моей карьеры в WebLogic я написал библиотеку на Java для разбора java-классов (файлов *.class). Она работала, но код был запутан, и его трудно было изменить или добавить новую функциональность. В течение нескольких лет я пытался переписать библиотеку, думая, что смогу использовать мои новые знания в Java и не увязнуть в куче дублирующегося кода, но так и не смог. Когда же я попробовал написать её на Common Lisp, это заняло всего 2 дня, и я получил не просто библиотеку для разбора java-классов, но библиотеку для разбора любых двоичных файлов. Вы увидите, как она работает, в главе 24, и используете её в главе 25 для разбора тэгов ID3 в MP3-файлах.

<p>Почему Lisp?</p>

Сложно объяснить на нескольких страницах введения, почему пользователи языка любят какой-то конкретный язык, ещё сложнее объяснить, почему вы должны тратить своё время на его изучение. Личный пример не слишком убеждает. Может быть, я люблю Lisp, потому что какая-то цепь в моём мозгу замкнулась. Это может быть даже генетическим отклонением, так как мой отец похоже тоже имел его. Так что прежде, чем вы погрузитесь в изучение языка Lisp, вполне естественным кажется желание узнать, что это вам даст, какую выгоду принесёт.

Для некоторых языков выгода очевидна. Например, если вы хотите писать низкоуровневые программы для Unix, то должны выучить C. Или если вы хотите писать кросс-платформенные приложения, то должны использовать Java. И большое число компаний до сих пор использует C++, так что если вы хотите получить работу в одной из них, то должны знать C++.

Тем не менее, для большинства языков выгоду не так просто выделить. Мы имеем дело с субъективными оценками того, насколько язык удобно использовать. Защитники Perl любят говорить, что он "делает простые вещи простыми, а сложные - возможными" и радуются факту, озвученному в девизе Perl - "Есть более чем один способ сделать это". [1] С другой стороны, фанаты языка Python думают, что Python прозрачный и простой язык, и код на Python проще понять, потому что, как гласит их лозунг, "Есть лишь один способ сделать это".

Так почему же Common Lisp? Здесь нет такой очевидной выгоды, как для C, Java или C++ (конечно, если вы не является счастливым обладателем Lisp-машины). Выгоды от использования Lisp заключены в переживаниях и впечатлениях от его использования. В остальной части книги я буду показывать отличительные черты языка, так что вы сможете по себе оценить, на что эти впечатления похожи. Сейчас я попытаюсь показать смысл философии Lisp.

В качестве девиза для Common Lisp лучше всего подходит похожее на дзенский коан описание "программируемый язык программирования". Хотя данный девиз выглядит несколько запутанно, он, тем не менее, выделяет суть преимущества, которое Lisp до сих пор имеет перед другими языками программирования. Больше, чем другие языки, Common Lisp следует философии: что хорошо для разработчика языка, то хорошо для его пользователей. Программируя на Common Lisp, вы, скорее всего, никогда не обнаружите нехватки каких-то возможностей в языке, которые упростили бы программирование, потому что, как будет показано далее, вы можете просто добавить эти возможности в язык.

Следовательно, программы на Common Lisp стараются предоставить наиболее прозрачное отображение между вашими идеями о том, как программа должна работать, и кодом, который вы пишете. Ваши идеи не замутняются нагромождением кода и бесконечно повторяющимися выражениями. Это делает ваш код более управляемым, потому что вам больше не приходится бродить по нему всякий раз, когда вы хотите внести какие-то изменения. Даже систематические изменения в программе могут быть достигнуты относительно малыми изменениями исходного кода. Это также означает, что вы будете писать код быстрее; вы будете писать меньше кода и не будете терять время на поиск пути для выражения своих идей в ограничениях, накладываемых языком программирования [2].

Common Lisp это также прекрасный язык для исследовательского программирования (прототипирования?), когда вам неизвестно достоверно, как ваша программа должна работать. Common Lisp предоставляет некоторые возможности, помогающие вам вести инкрементальную интерактивную разработку.

Интерактивный цикл read-eval-print, о котором я расскажу в следующей главе, позволяет вам непрерывно взаимодействовать с вашей программой во время её разработки. Пишете новую функцию. Тестируете её. Меняете её. Пробуете другие подходы к реализации. Вам не приходится останавливаться для длительной компиляции [3].

Другими особенностями, которые поддерживают непрерывный, интерактивный стиль программирования, являются динамическая типизация Lisp и система обработки условий в Lisp. Первое позволяет вам тратить меньше времени на убеждение компилятора в том, что вам можно запустить программу, и больше времени на её действительный запуск и работу с ней [4]. Последнее позволяет интерактивно разрабатывать даже код обработки ошибок.

Другим следствием того, что Lisp "программируемый язык" является то, что, кроме возможности вносить мелкие изменения в язык, которые позволяют легче писать программы, есть возможность без труда отражать в языке значительные, новые понятия, касающиеся общего устройства языков программирования. Например, первоначальная реализация Common Lisp Object System (CLOS) объектной системы Common Lisp, была библиотекой, написанной на самом Common Lisp. Это позволило Lisp программистам получить реальный опыт работы с возможностями, которые она предоставляла, еще до того момента, когда библиотека была официально включена в состав языка.

Какая бы новая парадигма программирования не появилась, Common Lisp, скорее всего, без труда сможет впитать её без изменений в ядре языка. Например, один программист на Lisp недавно написал библиотеку AspectL, которая добавляет Common Lisp поддержку аспектно-ориентированного программирования (AOP) [5]. Если будущее за AOP, то Common Lisp сможет поддерживать его без изменений в базовом языке и без дополнительных препроцессоров и прекомпиляторов [6].

<p>Как это началось?</p>

Common Lisp современный потомок языка программирования Lisp, придуманного Джоном Маккарти в 1956 году. Lisp был создан для "обработки символьных данных" [7] и получил своё имя от одной вещи, в которой он был очень хорош: обработки списков (LISt Processing). Много воды утекло с тех пор, и теперь Common Lisp обогащён набором современных типов данных, которые вам только могут понадобиться, а также системой обработки ситуаций, которая, как вы увидите в главе 19, предоставляет уровень гибкости, отсутствующий в системах обработки исключений таких языков, как C++, Java, Python; мощной системой объектно-ориентированного программирования; несколькими особенностями, которых нет ни в одном другом языке. Как такое возможно? Что, скажите, обусловило превращение Lisp в такой богатый язык?

Маккарти был (и есть) исследователем в области искусственного интеллекта, и многие особенности, которые он заложил в первую версию, сделало этот язык замечательным инструментом для программирования искусственного интеллекта. Во время бума ИИ в 80-е Lisp оставался излюбленным языком для решения сложных проблем, как то: автоматическое доказательство теорем, планирование и составление расписаний, компьютерное зрение. Это были проблемы, требующие сложных программ, для написания которых нужен был мощный язык, так что программисты ИИ сделали Lisp таковым. Помогла и Холодная война, т.к. Пентагон выделял деньги Управлению перспективных исследовательских программ (DARPA), часть этих денег попадала к людям, занимающимся моделированием крупных сражений, автоматическим планированием и интерфейсами на естественных языках. Эти люди также использовали Lisp и продолжали совершенствовать его, чтобы язык полностью удовлетворял их потребностям.

Те же силы, что развивали Lisp, также расширяли границы и в других направлениях сложные проблемы ИИ требуют больших вычислительных ресурсов, как бы вы их ни решали, и если вы примените закон Мура в обратном порядке, то сможете себе представить, сколь скудными эти ресурсы были в 80-е. Так что разработчики должны были найти все возможные пути улучшения производительности их реализаций языка. В результате этих усилий современные реализации Common Lisp часто включают в себя сложные компиляторы в язык, понятный машине. Хотя сегодня, благодаря закону Мура, возможно получить высокую производительность даже интерпретируемых языков, это больше не является проблемой для Common Lisp. И, как я покажу в главе 32, используя специальные (дополнительные) объявления, с помощью хорошего компилятора можно получить вполне приличный машинный код, сравнимый с тем, который выдаст компилятор C.

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

Фактически, к началу 80-х существовало множество Lisp-лабораторий и несколько компаний, каждая со своей реализацией Lisp, их было так много, что люди из DARPA стали высказывать свои опасения о разобщённости Lisp-сообщества. Чтобы достигнуть единства, группа Lisp-хакеров собралась вместе и начала процесс стандартизации нового языка, Common Lisp, который бы впитал в себя лучшие черты существующих диалектов. Их работа запечатлена в книге Common Lisp the Language Гая Стила (Guy Steele, Digital Press, 1984) (CLtL).

К 1986 году существовало несколько реализаций стандарта, призванного заменить разобщённые диалекты. В 1996 организация The American National Standards Institute (ANSI) выпустила стандарт, расширяющий Common Lisp на базе CLtL, добавив в него новую функциональность, такую, как CLOS и систему обработки условий. Но и это не было последним словом: как CLtL до этого, так и стандарт ANSI теперь целенаправленно позволяет разработчикам реализаций экспериментировать с тем, как лучше сделать те или иные вещи: реализация Lisp содержит богатую среду исполнения с доступом к графическому пользовательскому интерфейсу, многопоточности, сокетам TCP/IP и многому другому. В наши дни Common Lisp эволюционирует, как и большинство других языков с открытым кодом: люди, использующие его, пишут библиотеки, которые им необходимы, и часто делают их доступными для всего сообщества. В последние годы, в частности, замечается усиление активности в разработке для Lisp библиотек с открытым кодом.

Так что, с одной стороны, Lisp один из классических языков в информатике (Computer Science), базирующийся на идеях, проверенных временем [8]. С другой стороны, Lisp современный язык общего назначения, с дизайном, отражающим прагматический подход к решению сложных задач с максимальной надёжностью и эффективностью. Единственным недостатком "классического" наследия Лиспа является то, что многие все еще топчутся вокруг представлений о Лиспе, основанных на определенном диалекте этого языка, который они открыли для себя в середине прошлого столетия в то время, когда Маккарти разработал Лисп. Если кто-то говорит вам, что Lisp только интерпретируемый язык, что он медленный, или что вы обязаны использовать рекурсию буквально для всего, спросите вашего оппонента, какой диалект Lisp'а имеется в видy, и носили ли люди клёш, когда он изучал Lisp [9].

<p>Но я изучал Lisp раньше, и он не был тем, что вы описываете!</p>

Если вы изучали Lisp в прошлом, то можете подумать, что тот Lisp не имеет ничего общего с Common Lisp. Хотя Common Lisp вытеснил большинство диалектов, от которых он был порождён, это не единственный сохранившийся диалект, и, в зависимости от того, где и когда вы встретились с Lisp, вы могли хорошо изучить один из этих, отличных от Common Lisp, диалектов.

Кроме Common Lisp, активное сообщество пользователей есть у диалекта Lisp общего назначения под названием Scheme. Common Lisp позаимствовал из Scheme несколько важных особенностей, но никогда не пытался заменить его.

Разработанный в Массачуссетском Технологическом Институте (MIT), Scheme был быстро принят в качестве языка для начальных курсов по вычислительной технике. Scheme изначально занимал отдельную нишу, в частности, проектировщики языка постарались сохранить ядро Scheme настолько малым и простым, насколько это возможно. Это давало очевидные выгоды при использовании Scheme как языка для обучения, а также для исследователей в области языков программирования, которым важна возможность формального доказательства предположений о языке.

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

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

Также, если вы изучали Scheme, вас могут сбить с толку некоторые различия между Scheme и Common Lisp. Эти различия являются поводом непрекращающихся религиозных войн между горячими парнями, программирующими на этих диалектах. В данной книге я постараюсь указать на наиболее существенные различия.

Двумя другими распространёнными диалектами Lisp являются ELisp, язык расширений для редактора Emacs, и Autolisp, язык расширений для программы Autodesk AutoCAD. Хотя, возможно, суммарный объём кода, написанного на этих диалектах, перекрывает весь остальной код, написанный на Lisp, оба эти диалекта могут использоваться только в рамках приложений, которые они расширяют. Кроме того, они являются устаревшими по сравнению и с Common Lisp, и с Scheme. Если Вы использовали один из этих диалектов, приготовьтесь к путешествию на Lisp-машине времени на несколько десятилетий вперёд.

<p>Для кого эта книга?</p>

Эта книга для вас, если вы интересуетесь Common Lisp, независимо от того, знаете ли вы его или просто хотите понять, из-за чего вокруг него разгорелась вся эта шумиха.

Если вы уже изучали Lisp, но не смогли перейти от академических упражнений к созданию реальных полезных программ, эта книга покажет вам путь для такого перехода. С другой стороны, вы не обязаны желать применять Lisp для того, чтобы получить пользу от данной книги.

Если вы упёртый прагматик, желающий знать достоинства Common Lisp перед другими языками, такими, как Perl, Python, Java, C или C#, эта книга даст вам несколько идей по этому поводу. Или, может быть, вам нет никакого дела до использования Lisp и вы уверены, что он ничуть не лучше языков, которые вы уже знаете, но вам надоели заявления какого-нибудь Lisp-программиста, что вы просто не поняли его как следует. Если так, то в данной книге вы найдёте краткое введение в Common Lisp. Если после чтения этой книги вы по-прежнему будете думать, что Common Lisp ничем не лучше, чем ваши любимые языки, у вас будут веские обоснованные аргументы.

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

После окончания чтения книги вы будете знакомы с большинством важнейших возможностей языка и с тем, как их следует использовать. Вы будете иметь опыт использования Common Lisp для написания нетривиальных программ и будете готовы к дальнейшему самостоятельному изучению языка. И, хотя у каждого свой путь к Lisp, я надеюсь, данная книга поможет вам на этом пути. Итак, приступим!

2
<p>2. Намылить, смыть, повторить: знакомство с REPL</p>

В этой главе вы настроите среду программирования и напишете свои первые программы на Common Lisp. Мы воспользуемся лёгким в установке дистрибутивом Lisp in a Box , разработанным Matthew Danish и Mikel Evins , включающим в себя реализацию Common Lisp, мощным, прекрасно поддерживающим Lisp текстовым редактором Emacs , а также SLIME [10] средой разработки на Common Lisp, основанной на Emacs.

Этот набор предоставляет программисту современную среду разработки на Common Lisp, поддерживающую инкрементальный интерактивный стиль разработки, характерный для программирования на этом языке. Среда SLIME даёт дополнительное преимущество в виде унифицированного пользовательского интерфейса, не зависящего от выбранных вами операционной системы и реализации Common Lisp. В своей книге я буду ориентироваться на среду Lisp in a Box, но те, кто хочет изучить другие среды разработки, например, графические интегрированные среды разработки (IDE - Integrated Development Environment), предоставляемые некоторыми коммерческими поставщиками, или среды, основанные на других текстовых редакторах, не должны испытывать больших трудностей в понимании. [11]

<p>Выбор реализации Lisp</p>

Первое, что вам предстоит сделать выбрать реализацию Lisp. Это может показаться немного странным тем, кто раньше занимался программированием на таких языках как Perl, Python, Visual Basic (VB), C# или Java. Разница между Common Lisp и этими языками заключается в том, что Common Lisp определяется своим стандартом: не существует ни единственной его реализации, контролируемой "великодушным диктатором" (как в случае с Perl и Python), ни канонической реализации, контролируемой одной компанией (как в случае с VB, C# или Java). Любой желающий может создать свою реализацию на основе стандарта. Кроме того, изменения в стандарт должны вноситься в соответствии с процессом, контролируемым Американским Национальным Институтом Стандартов (ANSI). Этот процесс организован таким образом, что "случайные лица", такие, как частные поставщики программных решений, не могут вносить изменения в стандарт по своему усмотрению [12]. Таким образом, стандарт Common Lisp это договор между поставщиком Common Lisp и использующими Common Lisp разработчиками; этот договор подразумевает, что, если вы пишете программу, использующую возможности языка так, как это описано в стандарте, вы можете рассчитывать, что эта программа запустится на любой совместимой реализации Common Lisp.

С другой стороны, стандарт может описывать не всё из того, что вам может понадобиться в ваших программах. Более того, на некоторые аспекты языка спецификация намеренно отсутствует, чтобы дать возможность поэкспериментировать с различными способами их реализации, если при разработке стандарта не было достигнуто договорённости о наилучшем способе. Как видите, каждая реализация предоставляет пользователям как входящие в стандарт возможности, так и возможности, выходящие за его пределы. В зависимости от того, программированием какого рода вы собираетесь заняться, вы можете выбрать реализацию Common Lisp, поддерживающую именно те дополнительные возможности, которые вам больше всего понадобятся. С другой стороны, если вы предоставите другим разработчикам пользоваться вашим кодом на Lisp, например, разработанными вами библиотеками, вы, вероятно, захотите конечно, в пределах возможного писать переносимый код на Common Lisp. Для нужд написания кода, который должен быть переносимым, но, в тоже время, использовать возможности, не описанные в стандарте, Common Lisp предоставляет гибкий способ писать код, "зависящий" от возможностей текущей реализации. Вы увидите пример такого кода в главе 15, когда мы будем разрабатывать простую библиотеку, "сглаживающую" некоторые различия в обработке разными реализациями Lisp имён файлов.

Сейчас, однако, наиболее важная характеристика реализации её способность работать в вашей любимой операционной системе. Сотрудники компании Franz, занимающейся разработкой Allegro Common Lisp, выпустили пробную версию своего продукта, предназначенного для использования с этой книгой, и выполняющегося на GNU/Linux, Windows и OS X. У читателей, предпочитающих реализации с открытыми исходными текстами, есть несколько вариантов. SBCL [13] - высококачественная открытая реализация, способная компилировать в машинный код и работать на множестве различных UNIX-систем, включая Linux и OS X. SBCL "наследник" CMUCL [14] реализации Common Lisp, разработанной в университете Carnegie Mellon , и, как и CMUCL, является всеобщим достоянием (public domain, за исключением нескольких секций, покрываемых BSD-подобными (Berkley Software Distributions) лицензиями). CMUCL тоже хороший выбор, однако SBCL, обычно, легче в установке и поддерживает 21-разрядный Unicode [15]. OpenMCL будет отличным выбором для пользователей OS X: эта реализация способна компилировать в машинный код, поддерживать работу с потоками, а также прекрасно интегрируется с инструментальными комплектами Carbon и Cocoa. Кроме перечисленных, существуют и другие свободные и коммерческие реализации. Если вы захотите получить больше информации, в главе 32 вы найдёте список ресурсов.

Весь код на Lisp, приведённый в этой книге, должен работать на любой совместимой реализации Common Lisp, если явно не указано обратное, и SLIME будет "сглаживать" некоторые различия между реализациями, предоставляя общий интерфейс для взаимодействия с Lisp. Сообщения интерпретатора, приведённые в этой книге, сгенерированы Allegro , запущенном на GNU/Linux . В некоторых случаях другие реализации Lisp могут генерировать сообщения, незначительно отличающиеся от приведённых.

<p>Введение в Lisp in a Box</p>

Lisp in a Box спроектирован с целью быть "дружелюбным" к Лисперам-новичкам и предоставлять первоклассную среду разработки на Lisp с минимальными усилиями, и потому всё что вам нужно для работы - это взять соответствующий пакет для вашей операционной системы и выбранную вами реализацию Lisp с веб-сайта Lisp in a Box (см. главу 32) и далее следовать инструкциям по установке.

Так как Lisp in a Box использует Emacs в качестве текстового редактора, вы должны хоть немного уметь им пользоваться. Возможно, лучший способ начать работать с Emacs - это изучать его по встроенному учебнику (tutorial). Чтобы вызвать tutorial, выберете первый пункт меню Help Emacs tutorial. Или же зажмите Ctrl и нажмите h, затем отпустите Ctrl и нажмите t. Большинство команд в Emacs доступно через комбинации клавиш, поэтому они будут встречаться довольно часто, и чтобы долго не описывать комбинации (например: "зажмите Ctrl и нажмите h, затем..."), в Emacs существует краткая форма записи комбинаций клавиш. Клавиши, которые должны быть нажаты вместе, пишутся вместе, разделяются тире, и называются связками; связки разделяются пробелами. C обозначает Ctrl, а M - Meta (Alt). Например вызов tutorial будет выглядеть таким образом: C-h t.

Tutorial также описывает много других полезных команд Emacs и вызывающих их комбинаций клавиш. У Emacs также есть расширенная онлайн документация, для просмотра которой используется специальный браузер Info. Чтобы её вызвать нажмите C-h i. У Info также есть своя справка, которую можно вызвать, нажав клавишу h, находясь в браузере Info. Emacs предоставляет ещё несколько способов получить справку это все сочетания клавиш, начинающиеся с C-h полный список по C-h ?. В этом списке есть две полезные вещи: C-h k "объяснит" комбинацию клавиш, а C-h w команду.

Ещё одна важная часть терминологии (для тех, кто отказался от работы с tutorial) - это буфер. Во время работы в Emacs, каждый файл, который Вы редактируете, представлен в отдельном буфере. Только один буфер может быть "текущим" в любой момент времени. В текущий буфер поступает весь ввод всё, что Вы печатаете и любые команды, которые вызываете. Буферы также используются для представления взаимодействия с программами (например с Common Lisp). Есть одна простая вещь, которую вы должны знать "переключение буферов", означающее смену текущего буфера, так что Вы можете редактировать определённый файл или взаимодействовать с определённой программой. Команда switch-to-buffer, привязанная к комбинации клавиш C-x b, запрашивает имя буфера (в нижней части окна Emacs). Во время ввода имени буфера, Вы можете пользоваться автодополнением по клавише Tab, которое по начальным символам завершает имя буфера или выводит список возможных вариантов. Просто нажав ввод, Вы переключитесь в буфер "по-умолчанию" (таким же образом и обратно). Вы также можете переключать буферы, выбирая нужный пункт в меню Buffers.

В определенных контекстах для переключения на определенные буферы могут быть доступны другие комбинации клавиш. Например, при редактировании исходных файлов Lisp сочетание клавиш C-c C-z переключает на буфер, в котором вы взаимодействуете с Lisp.

<p>Освободите свой разум: Интерактивное программирование</p>

При запуске Lisp in a Box, вы должны увидеть приглашение, которое может выглядеть примерно так :

CL-USER>

Это приглашение Lisp. Как и приглашение оболочки DOS или UNIX, приглашение Lisp это место, куда вы можете печатать выражения, которые заставляют что-либо делать компьютер. Однако вместо того, чтобы считывать и выполнять строку команд оболочки, Lisp считывает Lisp выражения, вычисляет их согласно правилам Lisp и печатает результат. Потом он (lisp) повторяет свои действия со следующим введенным вами выражением. Вот вам бесконечный цикл: считывания, вычисления, и печати(вывода на экран), поэтому он называется цикл-чтение-вычисление-печать (по-английски read-eval-print-loop ), или сокращённо REPL . Этот процесс может также называться top-level , top-level listener , или Lisp listener .

Через окружение, предоставленное REPL'ом, вы можете определять и переопределять элементы программ такие как переменные, функции, классы и методы; вычислять выражения Lisp; загружать файлы, содержащие исходные тексты Lisp или скомпилированные программы; компилировать целые файлы или отдельные функции; входить в отладчик; пошагово выполнять программы; и проверять состояние отдельных объектов Lisp.

Все эти возможности встроены в язык, и доступны через функции, определённые в стандарте языка. Если вы захотите, вы можете построить достаточно приемлемую среду разработки только из REPL и текстового редактора, который знает как правильно форматировать код Lisp. Но для истинного опыта Lisp программирования вам необходима среда разработки типа SLIME, которая бы позволяла вам взаимодействовать с Lisp как посредством REPL, так и при редактировании исходных файлов. Например, вы ведь не захотите каждый раз копировать и вставлять куски кода из редактора в REPL или перезагружать весь файл только потому, что изменилось одно определение, ваше окружение должно позволять вам вычислять или компилировать как отдельные выражения так и целые файлы из вашего редактора [16].

<p>Эксперименты в REPL</p>

Для знакомства с REPL, вам необходимо выражение Lisp, которое может быть прочитано, вычислено и выведено на экран. Простейшее выражение Lisp - это число. Если вы наберете 10 в приглашении Lisp и нажмете ВВОД, то сможете увидите что-то наподобие:

CL-USER> 10

10

Первая 10 - это то, что вы набрали. Считыватель Lisp, R в REPL, считывает текст "10" и создаёт объект Lisp, представляющий число 10. Этот объект - самовычисляемый объект, это означает, что такой объект при передаче в вычислитель, E в REPL, вычисляется сам в себя. Это значение подаётся на устройство вывода REPL, которое напечатает объект "10" в отдельной строке. Хотя это и похоже на сизифов труд, можно получить что-то поинтереснее, если дать интерпретатору Lisp пищу для размышлений. Например, вы можете набрать (+ 2 3) в приглашении Lisp.

CL-USER> (+ 2 3)

5

Все что в скобках - это список, в данном случае список из трех элементов: символ + , и числа 2 и 3. Lisp, в общем случае, вычисляет списки, считая первый элемент именем функции, а остальные - выражениями для вычисления и передачи в качестве аргументов этой функции. В нашем случае, символ + - название функции которая вычисляет сумму. 2 и 3 вычисляются сами в себя и передаются в функцию суммирования, которая возвращает 5. Значение 5 отправляется на устройство вывода, которое отображает его. Lisp может вычислять выражения и другими способами, но не будем сильно отдаляться от основной темы. В первую очередь вы должны написать . . .

<p>"Здравствуй, Мир" в стиле Lisp</p>

Нет законченной книги по программированию без программы "Здравствуй, мир"("hello, world.") [17]. После того как интерпретатор запущен, нет ничего проще чем набрать строку "Здравствуй, мир".

CL-USER> "Здравствуй, мир"

"Здравствуй, мир"

Это работает, поскольку строки, также как и числа, имеют символьный синтаксис, понимаемый считывателем Lisp, и являются самовычисляемыми объектами: Lisp считывает строку в двойных кавычках и создает в памяти строковой объект, который при вычислении вычисляется сам в себя и потом печатается в том же символьном представлении. Кавычки не являются частью строкового объекта в памяти - это просто синтаксис, который позволяет считывателю определить, что этот объект - строка. Устройство вывода REPL напечатает кавычки тоже, потому что оно пытается выводить объекты в таком же виде, в каком понимает их считыватель.

Однако, наш пример не может квалифицироваться как программа "Здравствуй мир". Это, скорее, значение "Здравствуй мир".

Вы можете сделать шаг к настоящей программе, напечатав код, который, в качестве побочного эффекта, отправит на стандартный вывод строку "Здравствуй, мир". Common Lisp предоставляет несколько путей для вывода данных, но самый гибкий - это функция FORMAT. FORMAT получает переменное количество параметров, но только два из них обязательны: указание, куда осуществлять вывод, и строка для вывода. В следующей главе Вы увидите, как строка может содержать встроенные директивы, которые позволяют вставлять в строку последующие параметры функции (а-ля printf или строка % из Python). До тех пор, пока строка не содержит символа ~ , она будет выводиться как есть. Если вы передадите t в качестве первого параметра, функция FORMAT направит отформатированную строку на стандартный вывод. Итак, выражение FORMAT для печати "Здравствуй, мир" выглядит примерно так: [18]

CL-USER> (format t "Здравствуй, мир")

Здравствуй, мир

NIL

Стоит заметить, что результатом выражения FORMAT является NIL в строке после вывода "Здравствуй, мир". Этот NIL является результатом вычисления выражения FORMAT, напечатанного REPL. (NIL это Lisp-версия false и/или null. Подробнее об этом рассказывается в главе 4.) В отличие от других выражений, рассмотренных ранее, нас больше интересует побочный эффект выражения FORMAT (в данном случае, печать на стандартный вывод), чем возвращаемое им значение. Но каждое выражение в Lisp вычисляется в некоторый результат [19].

Однако, до сих пор остается спорным, написали ли мы настоящую программу. Но вы ведь здесь. Вы видите восходящий стиль программирования, поддерживаемый REPL: вы можете экспериментировать с различными подходами и строить решения из уже протестированных частей. Теперь, когда у вас есть простое выражение, которое делает то, что вы хотите, нужно просто упаковать его в функцию. Функции являются одним из основных строительных материаллов в Lisp и могут быть определены с помощью выражения DEFUN подобным образом:

CL-USER> (defun hello-world () (format t "hello, world"))

HELLO-WORLD

Выражение hello-world, следующее за DEFUN, является именем функции. В главе 4 мы рассмотрим, какие именно символы могут использоваться в именах, но сейчас будет достаточно сказать, что многие символы, такие как <<-", которые нелегальны в именах в других языках, абсолютно легальны в Common Lisp. Это стандартный стиль Lisp "not to mention more in line with normal English typography" формирование составных имен с помощью дефисов, как в hello-world, вместо использования знаков подчеркивания, как в hello_world, или использованием заглавных букв внутри имени, как helloWorld. Скобки () после имени отделяют список параметров, который в данном случае пуст, так как функция не принимает аргументов. Остальное - это тело функции.

В какой-то мере это выражение подобно всем другим, которые вы видели, всего лишь еще одно выражение для чтения, вычисления и печати, осуществляемых REPL. Возвращаемое значение в этом случае - это имя только что определенной функции [20]. Но, подобно выражению FORMAT, это выражение более интересно своими побочными эффектами, нежели возвращаемым значением. Однако, в отличие от выражения FORMAT, побочные эффекты невидимы: после вычисления этого выражения создается новая функция, не принимающая аргументов, с телом (format t "hello, world" ) и ей дается имя HELLO-WORLD.

Теперь, после определения функции, вы можете вызвать ее следующим образом:

CL-USER> (hello-world)

hello, world

NIL

Вы можете видеть, что вывод в точности такой же, как при вычислении выражения FORMAT напрямую, включая значение NIL, напечатанное REPL. Функции в Common Lisp автоматически возвращают значение последнего вычисленного выражения.

<p>Сохранение вашей работы</p>

Вы могли бы утверждать, что это готовая программа "hello, world". Однако, остаётся одна проблема. Если вы выйдете из Lisp и перезапустите его, определение функции исчезнет. Написав такую изящную функцию, вы захотите сохранить вашу работу.

Это достаточно легко. Вы просто должны создать файл, в котором сохраните определение. В Emacs вы можете создать новый файл набрав C-x C-f, и затем, когда Emacs выведет подсказку, введите имя файла, который вы хотите создать. Не особо важно, где будет находиться этот файл. Обычно исходные файлы Common Lisp именуются с расширением .lisp, хотя некоторые люди предпочитают .cl.

Открыв файл, вы можете набирать определение функции, введённое ранее в области REPL. Обратите внимание, что после набора открывающей скобки и слова DEFUN, в нижней части окна Emacs SLIME подскажет вам предполагаемые аргументы. Точная форма зависит от используемой вами реализации Common Lisp, но вы вероятно увидите что-то вроде этого:

(defun name varlist &rest body)

Сообщение будет исчезать, когда вы будете начинать печатать каждый новый элемент, и снова появляться после ввода пробела. При вводе определения в файл, вы можете захотеть разбить определение после списка параметров так, чтобы оно занимало две строки. Если вы нажмете Enter, а затем Tab, SLIME автоматически выровняет вторую строку соответствующим образом [21]:

(defun hello-world ()

(format t "hello, world"))

SLIME также поможет вам в согласовании скобок как только вы наберете закрывающую скобку, SLIME подсветит соответствующую открывающую скобку. Или вы можете просто набрать C-c C-q для вызова команды slime-close-parens-at-point, которая вставит столько закрывающих скобок, сколько нужно для согласования со всем открытыми скобками.

Теперь вы можете отправить это определение в вашу среду Lisp несколькими способами. Самый простой - это набрать C-c C-c, когда курсор находится где-нибудь внутри или сразу после формы DEFUN, что вызовет команду slime-compile-defun, которая, в свою очередь, пошлет определение в Lisp для вычисления и компиляции. Для того, чтобы убедиться, что это работает, вы можете сделать несколько изменений в hello-world, перекомпилировать ее, а затем вернуться назад в REPL, используя C-c C-z или C-x b, и вызвать ее снова. Например, вы можете сделать эту функцию более грамматически правильной.

(defun hello-world ()

(format t "Hello, world!"))

Теперь перекомпилируем ее с помощью C-c C-c и перейдем в REPL, набрав C-c C-z, чтобы попробовать новую версию.

CL-USER> (hello-world)

Hello, world!

NIL

Теперь вы возможно захотите сохранить файл, с которым работаете; находясь в буфере hello.lisp, наберите C-x C-s для вызова функции Emacs save-buffer.

Теперь, для того, чтобы попробовать перезагрузить эту функцию из файла с исходным кодом, вы должны выйти из Lisp и перезапустить его. Для выхода вы можете использовать клавишную комбинацию SLIME: находясь в REPL, наберите запятую. Внизу окна Emacs вам будет предложено ввести команду. Наберите quit (или sayoonara), а затем нажмите Enter. Произойдет выход из Lisp, а все окна, созданные SLIME (такие как буфер REPL), закроются [22]. Теперь перезапустите SLIME, набрав M-x slime.

Просто ради интереса, вы можете попробовать вызвать hello-world.

CL-USER> (hello-world)

После этого возникнет новый буфер SLIME, содержимое которого будет начинаться с чего-то вроде этого:

attempt to call `HELLO-WORLD' which is an undefined function.

[Condition of type UNDEFINED-FUNCTION]

Restarts:

0: [TRY-AGAIN] Try calling HELLO-WORLD again.

1: [RETURN-VALUE] Return a value instead of calling HELLO-WORLD.

2: [USE-VALUE] Try calling a function other than HELLO-WORLD.

3: [STORE-VALUE] Setf the symbol-function of HELLO-WORLD and call it again.

4: [ABORT] Abort handling SLIME request.

5: [ABORT] Abort entirely from this process.

Backtrace:

0: (SWANK::DEBUG-IN-EMACS #<UNDEFINED-FUNCTION @ #x716b082a>)

1: ((FLET SWANK:SWANK-DEBUGGER-HOOK SWANK::DEBUG-IT))

2: (SWANK:SWANK-DEBUGGER-HOOK #<UNDEFINED-FUNCTION @ #x716b082a> #<Function SWANK-DEBUGGER-HOOK>)

3: (ERROR #<UNDEFINED-FUNCTION @ #x716b082a>)

4: (EVAL (HELLO-WORLD))

5: (SWANK::EVAL-REGION "(hello-world)

" T)

Что же произошло? Просто вы попытались вызвать функцию, которая не существует. Но не смотря на такое количество выведенной информации, Lisp на самом деле обрабатывает такую ситуацию изящно. В отличие от Java или Python, Common Lisp не просто генерирует исключение и разворачивает стек. И он точно не завершается, оставив после себя образ памяти (dump core), только потому, что вы попытались вызвать несуществующую функцию. Вместо этого он перенесет вас в отладчик.

Во время работы с отладчиком вы все еще имеете полный доступ к Lisp, поэтому вы можете вычислять выражения для исследования состояния вашей программы и может быть даже для исправления каких-то вещей. Сейчас не стоит беспокоиться об этом; просто наберите q для выхода из отладчика и возвращения назад в REPL. Буфер отладчика исчезнет, а REPL выведет следующее:

CL-USER> (hello-world)

; Evaluation aborted

CL-USER>

Конечно, в отладчике можно сделать гораздо больше, чем просто выйти из него в главе 19 мы увидим, например, как отладчик интегрируется с системой обработки ошибок. А сейчас, однако, важной вещью, которую нужно знать, является то, что вы всегда можете выйти из отладчика и вернуться обратно в REPL, набрав q.

Вернувшись в REPL вы можете попробовать снова. Ошибка произошла, потому что Lisp не знает определения hello-world. Поэтому вам нужно предоставить Lisp определение, сохраненное нами в файле hello.lisp. Вы можете сделать это несколькими способами. Вы можете переключиться назад в буфер, содержащий файл (наберите C-x b, а затем введите hello.lisp) и перекомпилировать определение, как вы это делали ранее с помощью C-c C-c. Или вы можете загрузить файл целиком (что будет более удобным способом, если файл содержит множество определений) путем использования функции LOAD в REPL следующим образом:

CL-USER> (load "hello.lisp")

; Loading /home/peter/my-lisp-programs/hello.lisp

T

T означает, что загрузка всех определений произошла успешно [23]. Загрузка файла с помощью LOAD в сущности эквивалентна набору каждого выражения этого файла в REPL в том порядке, в каком они находятся в файле, таким образом, после вызова LOAD, hello-world должен быть определен.

CL-USER> (hello-world)

Hello, world!

NIL

Еще один способ загрузки определений файла - предварительная компиляция файла с помощью COMPILE-FILE, а затем загрузка (с помощью LOAD) уже скомпилированного файла, называемого FASL-файлом, что является сокращением для fast-load file (быстро загружаемый файл). COMPILE-FILE возвращает имя FASL-файла, таким образом мы можем скомпилировать и загрузить файла из REPL следующим образом:

CL-USER> (load (compile-file "hello.lisp"))

;;; Compiling file hello.lisp

;;; Writing fasl file hello.fasl

;;; Fasl write complete

; Fast loading /home/peter/my-lisp-programs/hello.fasl

T

SLIME также предоставляет возможность загрузки и компиляции файлов без использования REPL. Когда вы находитесь в буфере с исходным кодом, вы можете использовать C-c C-l для загрузки файла с помощью slime-load-file. Emacs выведет запрос имени файла для загрузки с уже введенным именем текущего файла; вы можете просто нажать Enter. Или же вы можете набрать C-c C-k для компиляции и загрузки файла, представляемого текущим буфером. В некоторых реализациях Common Lisp компилирование кода таким образом выполнится немного быстрее; в других - нет, обычно потому что они всегда компилируют весь файл целиком.

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

Кроме того, даже если приложение, написанное на Lisp, уже развернуто, часто существует возможность обратиться к REPL. В главе 26 вы увидите как можно использовать REPL и SLIME для взаимодействия с Lisp, запустившим Web-сервер, в то же самое время, когда он продолжает отдавать Web-страницы. Возможно даже использовать SLIME для соединения с Lisp, запущенным на другой машине, что позволяет, например, отлаживать удаленный сервер так же, как локальный.

И даже более впечатляющий пример удаленной отладки произошел в миссии NASA "Deep Space 1" в 1998 году. Через полгода после запуска космического корабля, небольшой код на Lisp должен был управлять космическим кораблем в течении двух дней для проведения серии экспериментов. Однако, неуловимое состояние гонки (race condition) в коде не было выявлено при тестировании на земле и было обнаружено уже в космосе. Когда ошибка была выявлена в космосе (100 миллионов миль от Земли) команда смогла произвести диагностику и исправление работающего кода, что позволило завершить эксперимент [24]. Один из программистов сказал об этом следующее:

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

Вы пока не готовы отправлять какой бы то ни было код Lisp в дальний космос, но в следующей главе вы напишите программу, которая немного более интересна, чем "hello, world".

3
<p>3. Практикум: Простая база данных</p>

Очевидно, перед тем, как создавать настоящие программы на Lisp, вам необходимо изучить язык. Но давайте смотреть правде в глаза вы можете подумать "Practical Common Lisp? Похоже на оксюморон. Зачем тратить силы на изучение деталей языка, если на нем невозможно сделать что-то дельное?". Итак, для начала я приведу маленький пример того, что можно сделать с помощью Common Lisp. В этой главе вы напишете простую базу данных для организации коллекции CD. В главе 27 вы будете использовать схожую технику при создании базы данных записей в формате MP3 для вашего потокового MP3-сервера. Фактически, можете считать это частью вашего программного проекта в конце концов, для того, чтобы иметь сколько-нибудь MP3-записей для прослушивания, совсем не помешает знать, какие записи у вас есть, а какие нужно извлечь с диска.

В этой главе я пройдусь по языку Lisp достаточно для того, чтобы вы продвинулись до понимания, каким образом работает код на нём. Но я не буду вдаваться в детали. Вы можете не беспокоиться, если что-то здесь будет вам непонятно в нескольких следующих главах все используемые здесь (а также многие другие) конструкции Common Lisp будут описаны гораздо более систематически.

Одно замечание по терминологии: в этой главе я расскажу о некоторых операторах Lisp. В главе 4 вы узнаете, что Common Lisp предоставляет три разных типа операторов: функции, макросы и операторы специального назначения. Для целей этой главы вам необязательно понимать разницу. Однако я буду ссылаться на различные операторы как на функции, макросы или специальные операторы, в зависимости от того, чем они на самом деле являются, вместо того, чтобы попытаться скрыть эти детали за одним словом оператор. Сейчас вы можете рассматривать функции, макросы и специальные операторы как более или менее эквивалентные сущности [25].

Также имейте ввиду, что я не буду использовать все наиболее сложные техники Common Lisp для вашей первой после "Hello, world" программы. Цель этой главы не в том, чтобы показать, как вам следует писать базу данных на Lisp; скорее, цель в том, чтобы вы получили представление, на что похоже программирование на Lisp и видение того, что даже относительно простая программа на Lisp может иметь много возможностей.

<p>CD и Записи</p>

Чтобы отслеживать диски, которые нужно перекодировать в MP3, и знать, какие из них должны быть перекодированы в первую очередь, каждая запись в базе данных будет содержать название и имя исполнителя компакт диска, оценку того, насколько он нравится пользователю, и флаг, указывающий, был ли диск уже перекодирован. Итак, для начала вам необходим способ представления одной записи в базе данных (другими словами, одного CD). Common Lisp предоставляет для этого много различных структур данных, от простого четырехэлементного списка до определяемого пользователем с помощью CLOS класса данных.

Для начала вы можете остановиться на простом варианте и использовать список. Вы можете создать его с помощью функции LIST , которая, соответственно, возвращает список [26] из переданных аргументов.

CL-USER> (list 1 2 3)

(1 2 3)

Вы могли бы использовать четырёхэлементный список, отображающий позицию в списке на соответствующее поле записи. Однако другая существующая разновидность списков, называемая property list (список свойств) или, сокращенно, plist , в нашем случае гораздо удобнее. Plist это такой список, в котором каждый нечетный элемент является символом , описывающим следующий (чётный) элемент списка. На этом этапе я не буду углубляться в подробности понятия символ ; по своей природе это имя. Для символов, именующих поля в базе данных, мы можем использовать частный случай символов, называемый символами-ключами ( keyword symbol ). Ключ это имя, начинающееся с двоеточия (:), например, :foo [27]. Вот пример plist , использующего символы-ключи :a, :b и :c как имена свойств:

CL-USER> (list :a 1 :b 2 :c 3)

(:A 1 :B 2 :C 3)

Заметьте, вы можете создать список свойств той же функцией LIST , которой создавали прочие списки. Характер содержимого вот что делает его списком свойств.

Причина, по который использование plist является предпочтительным наличие функции GETF , в которую передают plist и желаемый символ и получают следующее за символом значение. Это делает plist чем-то вроде упрощенной хэш-таблицы. В Lisp есть и "настоящие" хэш-таблицы, но для ваших текущих нужд достаточно plist , к тому же намного проще сохранять данные в такой форме в файл, это сильно пригодится позже.

CL-USER> (getf (list :a 1 :b 2 :c 3) :a)

1

CL-USER> (getf (list :a 1 :b 2 :c 3) :c)

3

Теперь, зная это, вам будет достаточно просто написать функцию make-cd , которая получит четыре поля в качестве аргументов и вернёт plist , представляющий CD.

(defun make-cd (title artist rating ripped)

(list :title title :artist artist :rating rating :ripped ripped))

Слово DEFUN говорит нам, [28] что эта запись определяет новую функцию. Имя функции make-cd . После имени следует список параметров. Функция содержит четыре параметра title , artist , rating и ripped . Всё, что следует за списком параметров тело функции. В данном случае тело лишь форма, просто вызов функции LIST . При вызове make-сd параметры, переданные при вызове, будут связаны с переменными в списке параметров из объявления функции. Например, для создания записи о CD Roses от Kathy Mattea вы можете вызвать make-cd примерно так:

CL-USER> (make-cd "Roses" "Kathy Mattea" 7 t)

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T)

<p>Заполнение CD</p>

Впрочем, создание одной записи ещё не создание базы данных. Вам необходима более комплексная структура данных для хранения записей. Опять же, простоты ради, список представляется здесь вполне подходящим выбором. Также для простоты вы можете использовать глобальную переменную *db*, которую можно будет определить с помощью макроса DEFVAR . Звездочки (*) в имени переменной это договоренность, принятая в языке Lisp при объявлении глобальных переменных. [29]

(defvar *db* nil)

Для добавления элементов в *db* можно использовать макрос PUSH . Но разумнее немного абстрагировать вещи и определить функцию 'add-record', которая будет добавлять записи в базу данных.

(defun add-record (cd) (push cd *db*))

Теперь вы можете использовать add-record вместе с make-cd для добавления CD в базу данных.

CL-USER> (add-record (make-cd "Roses" "Kathy Mattea" 7 t))

((:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

CL-USER> (add-record (make-cd "Fly" "Dixie Chicks" 8 t))

((:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T)

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

CL-USER> (add-record (make-cd "Home" "Dixie Chicks" 9 t))

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T)

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

Всё, что REPL выводит после каждого вызова add-record значения, возвращаемые последним выражением в теле функции, в нашем случае PUSH . А PUSH возвращает новое значение изменяемой им переменной. Таким образом, после каждого добавления порции данных вы видите содержимое вашей базы данных.

<p>Просмотр содержимого базы данных</p>

Вы также можете просмотреть текущее значение *db* в любой момент, набрав *db* в REPL.

CL-USER> *db*

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T)

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T))

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

TITLE: Home

ARTIST: Dixie Chicks

RATING: 9

RIPPED: T

TITLE: Fly

ARTIST: Dixie Chicks

RATING: 8

RIPPED: T

TITLE: Roses

ARTIST: Kathy Mattea

RATING: 7

RIPPED: T

Эта функция может выглядеть так:

(defun dump-db ()

(dolist (cd *db*)

(format t "~{~a:~10t~a~%~}~%" cd)))

Работа функции заключается в циклическом обходе всех элементов *db* с помощью макроса DOLIST , связывая на каждой итерации каждый элемент с переменной cd . Для вывода на экран каждого значения cd используется функция FORMAT .

Следует признать, вызов FORMAT выглядит немного загадочно. Но в действительности FORMAT не особенно сложнее, чем функция printf из С или Perl или оператор % из Python . В главе 18 я расскажу о FORMAT более подробно. Теперь же давайте шаг за шагом рассмотрим, как работает этот вызов. Как было показано в гл. 2, FORMAT принимает по меньшей мере два аргумента, первый из которых поток, в который FORMAT направляет свой вывод; t сокращённое обозначение потока *standard-output*.

Второй аргумент FORMAT формат строки; он может как содержать символьный текст, так и управляющие команды, контролирующие работу этой функции, например то, как она должна интерпретировать остальные аргументы. Команды, управляющие форматом вывода, начинаются со знака тильды ( ~ ) (так же, как управляющие команды printf начинаются с %). FORMAT может принимать довольно много таких команд, каждую со своим набором параметров [30]. Однако сейчас я сфокусируюсь только на тех управляющих командах, которые необходимы для написания функции dump-db .

Команда ~a служит для придания выводимым строкам некоторой эстетичности. Она принимает аргумент и возвращает его в удобочитаемой форме. Эта команда отобразит ключевые слова без предваряющего двоеточия, и строки без кавычек. Например:

CL-USER> (format t "~a" "Dixie Chicks")

Dixie Chicks

NIL

или:

CL-USER> (format t "~a" :title)

TITLE

NIL

Команда ~t предназначена для табулирования. Например, ~10t указывает FORMAT , что необходимо выделить достаточно места для перемещения в десятый столбец перед выполнением команды ~a . ~t не принимает аргументов.

CL-USER> (format t "~a:~10t~a" :artist "Dixie Chicks")

ARTIST: Dixie Chicks

NIL

Теперь рассмотрим немного более сложные вещи. Когда FORMAT обнаруживает ~{ , следующим аргументом должен быть список. FORMAT циклично просматривает весь список, на каждой итерации выполняя команды между ~{ и ~} и используя столько элементов списка, сколько нужно для вывода согласно этим командам. В функции dump-db FORMAT будет циклично просматривать список и на каждой итерации принимать одно ключевое слово и одно значение списка. Команда ~% не принимает аргументов, но заставляет FORMAT выполнять переход на новую строку. После выполнения команды ~} итерация заканчивается, и последняя ~% заставляет FORMAT сделать ещё один переход на новую строку, чтобы записи, соответствующие каждому CD, были разделены. Формально, вы также можете использовать FORMAT для вывода именно базы данных, сократив тело функции dump-db до одной строки.

(defun dump-db ()

(format t "~{~{~a:~10t~a~%~}~%~}" *db*))

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

<p>Улучшение взаимодействия с пользователем</p>

Хотя функция add-record прекрасно выполняет свои обязанности, она слишком необычна для пользователя, не знакомого с Lisp. И если он захочет добавить в базу данных несколько записей, это может показаться ему довольно неудобным. В этом случае вы возможно захотите написать функцию, которая будет запрашивать у пользователя информацию о нескольких CD. В этом случае, вам нужен какой-то способ запросить эту информацию у пользователя и считать её. Для этого создадим следующую функцию:

(defun prompt-read (prompt)

(format *query-io* "~a: " prompt)

(force-output *query-io*)

(read-line *query-io*))

Мы использовали уже знакомую нам функцию FORMAT , чтобы вывести приглашение. Заметим, что в строке, описывающей формат, отсутствует <<~%", поэтому перевода курсора на новую строку не происходит. Вызов FORCE-OUTPUT необходим в некоторых реализациях для уверенности в том, что Lisp не будет ожидать вывода новой строки перед выводом приглашения.

Теперь прочитаем одну строку текста с помощью (очень удачно названной!) функции READ-LINE . Переменная *QUERY-IO* является глобальной (о чем можно догадаться по наличию в её имени символов * ), она содержит входной поток, связанный с терминалом. Значение, возвращаемое функцией PROMPT-READ это значение последней ее формы, вызова READ-LINE , возвращающего прочитанную им строку (без завершающего символа новой строки).

Вы можете скомбинировать уже существующую функцию make-cd с prompt-read , чтобы построить функцию создания новой записи о CD из данных, которые make-cd по очереди получает для каждого значения.

(defun prompt-for-cd ()

(make-cd

(prompt-read "Title")

(prompt-read "Artist")

(prompt-read "Rating")

(prompt-read "Ripped [y/n]")))

Это почти правильно, если не считать того, что функция prompt-read возвращает строку. Это хорошо подходит для полей Title и Artist, но значения полей Rating и Ripped числовое и булево. В зависимости от того, насколько развитым вы хотите сделать пользовательский интерфейс, можете проверять подстроки произвольной длины, чтобы удостовериться в корректности введённых пользователем данных. Теперь давайте опробуем самый очевидный (хотя и не лучший) вариант: мы можем упаковать вызов prompt-read , запрашивающий у пользователя его оценку диска, в вызов специфичной для Lisp функции PARSE-INTEGER . Это можно сделать так:

(parse-integer (prompt-read "Rating"))

К сожалению, по умолчанию функция PARSE-INTEGER сообщает об ошибке, если ей не удаётся разобрать число из введённой строки, или если в строке присутствует "нечисловой" мусор. Однако она может принимать дополнительный параметр :junk-allowed, который позволит нам ненадолго расслабиться.

(parse-integer (prompt-read "Rating") :junk-allowed t)

Остается ещё одна проблема если PARSE-INTEGER не удастся выделить число среди "мусорных" данных, она вернёт не число, а NIL . Следуя нашему подходу "сделать просто, пусть даже не совсем правильно", мы в этом случае можем просто задать 0 и продолжить. Макрос OR здесь как раз то, что нужно. Это то же самое, что и операция || в Perl, Python, Java и C. Макрос принимает набор выражений и вычисляет их по одному, слева направо. Если какое-нибудь из них дает истинное значение, то оно возвращается как результат макроса OR , а остальные не вычисляются. Если все выражения оказываются ложными, тогда макрос OR возвращает ложь ( NIL ). Таким образом, используем следующую запись:

(or (parse-integer (prompt-read "Rating") :junk-allowed t) 0)

чтобы получить 0 в качестве значения по умолчанию.

Исправление кода для запроса состояния Ripped немного проще. Можно воспользоваться стандартной функцией Common Lisp Y-OR-N-P .

(y-or-n-p "Ripped [y/n]: ")

Фактически, этот вызов является самой отказоустойчивой частью prompt-for-cd , поскольку Y-OR-N-P будет повторно запрашивать у пользователя состояние флага Ripped, если он введет что-нибудь, начинающееся не с y , Y , n или N .

Собрав код вместе, получим достаточно надёжную функцию prompt-for-cd :

(defun prompt-for-cd ()

(make-cd

(prompt-read "Title")

(prompt-read "Artist")

(or (parse-integer (prompt-read "Rating") :junk-allowed t) 0)

(y-or-n-p "Ripped [y/n]: ")))

Наконец, мы можем закончить интерфейс добавления CD, упаковав prompt-for-cd в функцию, циклично запрашивающую пользователя о новых данных. Воспользуемся простой формой макроса LOOP , выполняющего выражения в своём теле до тех пор, пока его выполнение не будет прервано вызовом RETURN . Например:

(defun add-cds ()

(loop (add-record (prompt-for-cd))

(if (not (y-or-n-p "Another? [y/n]: ")) (return))))

Теперь с помощью add-cds добавим в базу несколько новых дисков.

CL-USER> (add-cds)

Title: Rockin' the Suburbs

Artist: Ben Folds

Rating: 6

Ripped [y/n]: y

Another? [y/n]: y

Title: Give Us a Break

Artist: Limpopo

Rating: 10

Ripped [y/n]: y

Another? [y/n]: y

Title: Lyle Lovett

Artist: Lyle Lovett

Rating: 9

Ripped [y/n]: y

Another? [y/n]: n

NIL

<p>Сохранение и загрузка базы данных</p>

Хорошо иметь удобный способ добавления записей в базу данных. Но пользователю вряд ли понравится заново добавлять все записи после каждого перезапуска Lisp. К счастью, используя текущие структуры данных, используемые для представления информации, сохранить данные в файл и загрузить их позже задача тривиальная. Далее приводится функция save-db , которая принимает в качестве параметра имя файла и сохраняет в него текущее состояние базы данных:

(defun save-db (filename)

(with-open-file (out filename

:direction :output

:if-exists :supersede)

(with-standard-io-syntax

(print *db* out))))

Макрос WITH-OPEN-FILE открывает файл, связывает поток с переменной, выполняет набор инструкций и затем закрывает файл. Он также гарантирует, что файл обязательно закроется, даже если во время выполнения тела макроса что-то пойдет не так. Список, находящийся сразу после WITH-OPEN-FILE , является не вызовом функции, а частью синтаксиса, определяемого этим макросом. Он содержит имя переменной, хранящей файловый поток, в который в теле макроса WITH-OPEN-FILE будет вестись запись, значение, которое должно быть именем файла, и несколько параметров, управляющих режимом открытия файла. В нашем примере файл будет открыт для записи (задаётся параметром :direction :output ), и, если файл с таким именем уже существует, его содержимое будет перезаписано (параметр :if-exists :supersede ).

После того, как файл открыт, всё, что вам нужно это печать содержимого базы данных с помощью (print *db* out) . В отличие от FORMAT , функция PRINT печатает объекты Lisp в форме, которую Lisp может прочитать. Макрос WITH-STANDARD-IO-SYNTAX гарантирует, что переменным, влияющим на поведение функции PRINT , присвоены стандартные значения. Используйте этот же макрос и при чтении данных из файла для гарантии совместимости операций записи и чтения.

Аргументом функции save-db должна являться строка, содержащая имя файла, в который пользователь хочет сохранить базу данных. Точный формат строки зависит от используемой операционной системы. Например, в Unix пользователь может вызвать функцию save-db таким образом:

CL-USER> (save-db "~/my-cds.db")

((:TITLE "Lyle Lovett" :ARTIST "Lyle Lovett" :RATING 9 :RIPPED T)

(:TITLE "Give Us a Break" :ARTIST "Limpopo" :RATING 10 :RIPPED T)

(:TITLE "Rockin' the Suburbs" :ARTIST "Ben Folds" :RATING 6 :RIPPED T)

(:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T)

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 9 :RIPPED T))

В Windows имя файла может выглядеть так: c:/my-cds.db . Или так: c: my-cds.db [31].

Вы можете открыть этот файл в любом текстовом редакторе и посмотреть, как выглядят записи. Вы должны увидеть что-то очень похожее на то, что печатает REPL, если вы наберёте *db* .

Функция загрузки базы данных из файла реализуется аналогично:

(defun load-db (filename)

(with-open-file (in filename)

(with-standard-io-syntax

(setf *db* (read in)))))

В этот раз нет необходимости задавать :direction в параметрах WITH-OPEN-FILE , так как её значение по умолчанию :input . И вместо печати вы используете функцию READ для чтения из потока in . Это тот же считыватель, что и в REPL, и он может прочитать любое выражение на Lisp, которое можно написать в строке приглашения REPL. Однако, в нашем случае, вы просто читаете и сохраняете выражение, не выполняя его. И снова, макрос WITH-STANDARD-IO-SYNTAX гарантирует, что READ использует тот же базовый синтаксис, что и функция save-db , когда она печатает данные с помощью PRINT .

Макрос SETF является главным оператором присваивания в Common Lisp. Он присваивает свому первому аргументу результат вычисления второго аргумента. Таким образом, в load-db переменная *db* будет содержать объект, прочитанный из файла, а именно, список списков, записанных функцией save-db . Обратите внимание на то, что load-db затирает то, что было в *db* до её вызова. Так что, если вы добавили записи, используя add-records или add-cds , и не сохранили их функцией save-db , эти записи будут потеряны.

<p>Выполнение запросов к базе данных</p>

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

(select :artist "Dixie Chicks")

и в ответ на этот запрос получить список всех записей исполнителя Dixie Chicks. И снова оказалось, что выбор списка в качестве контейнера данных был очень удачным.

Функция REMOVE-IF-NOT принимает предикат и список в качестве параметров и возвращает список, содержащий только элементы исходного списка, удовлетворяющие предикату. Другими словами, она удаляет все элементы, не удовлетворяющие предикату. На самом деле, REMOVE-IF-NOT ничего не удаляет она создает новый список, оставляя исходный список нетронутым. Эта операция аналогична работе утилиты grep. Предикатом может быть любая функция, принимающая один аргумент и возвращающая логическое значение NIL (ложь) или любое другое значение (истина).

Например, если вы хотите получить все чётные элементы из списка чисел, можете использовать REMOVE-IF-NOT таким образом:

CL-USER> (remove-if-not #'evenp '(1 2 3 4 5 6 7 8 9 10))

(2 4 6 8 10)

В этом случае предикатом является функция EVENP , которая возвращает "истину", если её аргумент чётное число. Нотация #' является сокращением выражения "Получить функцию с данным именем". Без #' Lisp обратится к EVENP как к имени переменной и попытается получить ее значение, а не саму функцию.

Вы также можете передать в REMOVE-IF-NOT анонимную функцию. Например, если бы EVENP не существовало, вы могли бы так написать предыдущее выражение:

CL-USER> (remove-if-not #'(lambda (x) (= 0 (mod x 2))) '(1 2 3 4 5 6 7 8 9 10))

(2 4 6 8 10)

В этом случае предикатом является анонимная функция

(lambda (x) (= 0 (mod x 2)))

которая проверяет, равен ли нулю остаток от деления аргумента на 2 (другими словами, является ли аргумент чётным). Если вы хотите извлечь только нечётные числа, используя анонимную функцию, вы можете написать следующее:

CL-USER> (remove-if-not #'(lambda (x) (= 1 (mod x 2))) '(1 2 3 4 5 6 7 8 9 10))

(1 3 5 7 9)

Заметьте, что lambda не является именем функции это слово показывает, что вы определяете анонимную функцию [32]. Если не считать имени, LAMBDA -выражение выглядит очень похожим на DEFUN : после слова lambda следует список параметров, за которым идёт тело функции.

Чтобы выбрать все альбомы Dixie Chicks из базы данных, используя REMOVE-IF-NOT , вам нужна функция, возвращающая "истину", если поле в записи artist содержит значение "Dixie Chicks". Помните, мы выбрали список свойств в качестве представления записей базы данных, потому что функция GETF может извлекать из списка свойств именованные поля. Итак, полагая, что cd является именем переменной, хранящей одну запись базы данных, вы можете использовать выражение (getf cd :artist) , чтобы извлечь имя исполнителя. Функция EQUAL посимвольно сравнивает переданные ей строковые параметры. Таким образом, (equal (getf cd :artist) "Dixie Chicks") проверит, хранит ли поле artist , для текущей записи в переменной cd, значение "Dixie Chicks". Всё, что вам нужно упаковать это выражение в LAMBDA -форму, чтобы создать анонимную функцию и передать ее REMOVE-IF-NOT .

CL-USER> (remove-if-not

#'(lambda (cd) (equal (getf cd :artist) "Dixie Chicks")) *db*)

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T))

Предположим теперь, что вы хотите упаковать всё выражение в функцию, которая принимает имя исполнителя в качестве параметра. Вы можете записать это так:

(defun select-by-artist (artist)

(remove-if-not

#'(lambda (cd) (equal (getf cd :artist) artist))

*db*))

Заметьте, что анонимная функция, содержащая код, который не будет выполнен, пока функция не вызвана в REMOVE-IF-NOT , тем не менее может ссылаться на переменную artist . В этом случае анонимная функция не просто избавляет вас от необходимости писать обычную функцию, она позволяет вам написать функцию, наследующую часть её значений FIXME содержимое поля artist из контекста в котором она вызывается.

Итак, мы покончили с функцией select-by-artist . Однако выборка по исполнителю лишь одна разновидность запросов, которые вам захочется реализовать. Вы можете написать ещё несколько функций, таких, как select-by-title , select-by-rating , select-by-title-and-artist , и так далее. Но все они будут идентичными, за исключением содержимого анонимной функции. Вместо этого вы можете создать более универсальную функцию select , которая принимает функцию в качестве аргумента.

(defun select (selector-fn)

(remove-if-not selector-fn *db*))

А что случилось с # '? Дело в том, что в этом случае вам не нужно, чтобы функция REMOVE-IF-NOT использовала функцию под названием selector-fn . Вы хотите, чтобы она использовала анонимную функцию, переданную в качестве аргумента функции select в переменной selector-fn . Однако, символ #' вернулся в вызов select :

CL-USER> (select #'(lambda (cd) (equal (getf cd :artist) "Dixie Chicks")))

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T))

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

(defun artist-selector (artist)

#'(lambda (cd) (equal (getf cd :artist) artist)))

artist-selector возвращает функцию, имеющую ссылку на переменную, которая перестанет существовать после выхода из artist-selector [33]. Функция выглядит странно, но она работает именно так, как нам нужно если вызвать artist-selector с аргументом "Dixie Chicks", мы получим анонимную функцию, которая ищет CD с полем :artist , содержащим "Dixie Chicks", и если вызвать её с аргументом "Lyle Lovett", то мы получим другую функцию, которая будет искать CD с полем :artist , содержащим "Lyle Lovett". Итак, мы можем переписать вызов select следующим образом:

CL-USER> (select (artist-selector "Dixie Chicks"))

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 9 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 8 :RIPPED T))

Теперь нам понадобится больше функций, чтобы генерировать выражения для выбора. Но так как вы не хотите писать select-by-title , select-by-rating и др., потому что они будут во многом схожими, вы не станете создавать множество почти идентичных генераторов выражений для выбора значений для каждого из полей. Почему бы не написать генератор функции-выражения для выбора общего назначения функцию, которая, в зависимости от передаваемых ей аргументов, будет генерировать выражение выбора для разных полей или, может быть, даже комбинации полей? Вы можете написать такую функцию, но сначала нам придётся пройти краткий курс для овладения средством, называемым параметрами-ключами (keyword parameters).

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

(defun foo (a b c) (list a b c))

имеет три параметра: a , b и c , и должна быть вызвана с тремя аргументами. Но иногда возникает необходимость в вызове функции, которая может вызываться с переменным числом аргументов. Параметры-ключи один из способов это сделать. Версия foo с использованием параметров-ключей может выглядеть так:

(defun foo (&key a b c) (list a b c))

Единственное отличие элемент &key в начале списка аргументов. Однако вызовы новой функции foo выглядят немного по-другому. Все нижеперечисленные варианты вызова foo допустимы, результат вызова помещён справа от

==>

.

(foo :a 1 :b 2 :c 3) ==> (1 2 3)

(foo :c 3 :b 2 :a 1) ==> (1 2 3)

(foo :a 1 :c 3) ==> (1 NIL 3)

(foo) ==> (NIL NIL NIL)

Как показывают эти примеры, значения переменных a , b и c привязаны к значениям, которые следуют за соответствующими ключевыми словами. И если какой-либо ключ в вызове отсутствует, соответствующая переменная устанавливается в NIL . Я не буду уточнять, как именно задаются ключевые параметры и как они соотносятся с другими типами параметров, но вам важно знать одну деталь.

Обычно, когда функция вызывается без аргумента для конкретного параметра-ключа, параметр будет иметь значение NIL . Но иногда нужно различать NIL , который был явно передан в качестве аргумента к параметру-ключу, и NIL , который задаётся по умолчанию. Чтобы сделать это, при задании параметра-ключа вы можете заменить обычное имя списком, состоящим из имени параметра, его значения по умолчанию и другого имени параметра, называемого параметром supplied-p . Этот параметр supplied-p будет содержать значения "истина" или "ложь", в зависимости от того, действительно ли для данного параметра-ключа в данном вызове функции был передан аргумент. Вот версия новой функции foo , которая использует эту возможность.

(defun foo (&key a (b 20) (c 30 c-p)) (list a b c c-p))

Результаты тех же вызовов теперь выглядят иначе:

(foo :a 1 :b 2 :c 3) ==> (1 2 3 T)

(foo :c 3 :b 2 :a 1) ==> (1 2 3 T)

(foo :a 1 :c 3) ==> (1 20 3 T)

(foo) ==> (NIL 20 30 NIL)

Основной генератор выражения выбора, который по причинам, которые, если вы знакомы с SQL, скоро станут очевидными, можно назвать where , является функцией, принимающей четыре параметра-ключа для соответствующих полей в наших записях CD и генерирующей выражение выбора, которое возвращает все записи о CD, совпадающие по значениями, задаваемым в where . Например, можно будет написать такое выражение:

(select (where :artist "Dixie Chicks"))

Или такое:

(select (where :rating 10 :ripped nil))

Функция выглядит так:

(defun where (&key title artist rating (ripped nil ripped-p))

#'(lambda (cd)

(and

(if title (equal (getf cd :title) title) t)

(if artist (equal (getf cd :artist) artist) t)

(if rating (equal (getf cd :rating) rating) t)

(if ripped-p (equal (getf cd :ripped) ripped) t))))

Эта функция возвращает анонимную функцию, возвращающую логичиеское И для одного условия в каждом поле записей о CD. Каждое условие проверяет, задан ли подходящий аргумент, и если задан, то сравнивает его значение со значением соответствующего поля в записи о CD, или возвращает t , обозначение истины в Lisp, если аргумент не был задан. Таким образом, выражение выбора возвратит t только для тех CD, описание которых совпало по значению с аргументами переданными where [34]. Заметьте, что, чтобы задать ключ-параметр ripped , вам необходимо использовать список из трёх элементов, потому что вам нужно знать, действительно ли вызывающая функция передала ключ-параметр :ripped nil , означающее "Выбрать те CD, в поле ripped которых установлено значение nil ", либо опустила его, что означает "Мне всё равно, какое значение установлено в поле ripped ".

<p>Обновление существующих записей — повторное использование where</p>

Теперь, после того, как у вас есть достаточно универсальные функции select и where , очень логичной представляется реализация следующей возможности, которая необходима каждой базе данных, возможности обновления отдельных записей. В SQL команда update используется для обновления набора записей, удовлетворяющих FIXME конкретному условию where . Эта модель кажется хорошей, особенно когда у вас уже есть генератор условий where . Фактически, функция update применение некоторых идей, которые вы уже видели: использование передаваемого выражения выбора для указания записей, подлежащих обновлению, и использование аргументов-ключей для задания нового значения. Новая вещь здесь использование функции MAPCAR , которая отображает [35] список, в нашем случае это *db* , и возвращает новый список, содержащий результаты вызова функции для каждого элемента исходного списка.

(defun update (selector-fn &key title artist rating (ripped nil ripped-p))

(setf *db*

(mapcar

#'(lambda (row)

(when (funcall selector-fn row)

(if title (setf (getf row :title) title))

(if artist (setf (getf row :artist) artist))

(if rating (setf (getf row :rating) rating))

(if ripped-p (setf (getf row :ripped) ripped)))

row) *db*)))

Ещё одна новинка в этой функции [36] приложение SETF к сложной форме вида (getf row :title) . Я расскажу о SETF подробнее в главе 6, но сейчас вам просто нужно знать, что это общий оператор присваивания, который может использоваться для присваивания друг другу различных "вещей", а не только переменных. (То, что SETF и GETF имеют настолько похожие имена просто совпадение. Между ними нет никакой особой взаимосвязи). Сейчас достаточно знать, что после выполнения (setf (getf row :title) title) у списка свойств, на который ссылается row , значением переменной, следующей за именем свойства :title , будет title. С помощью функции update , если вы решите, что действительно любите творчество Dixie Chicks, и что все их альбомы должны быть оценены в 11 баллов, можете выполнить следующую форму [37]:

CL-USER> (update (where :artist "Dixie Chicks") :rating 11)

NIL

Результат работы будет выглядеть так:

CL-USER> (select (where :artist "Dixie Chicks"))

((:TITLE "Home" :ARTIST "Dixie Chicks" :RATING 11 :RIPPED T)

(:TITLE "Fly" :ARTIST "Dixie Chicks" :RATING 11 :RIPPED T))

Добавить функцию удаления строк из базы данных еще проще.

(defun delete-rows (selector-fn)

(setf *db* (remove-if selector-fn *db*)))

Функция REMOVE-IF является дополнением к REMOVE-IF-NOT ; она возвращает список всех элементов, удалив те из них, что удовлетворяют предикату. Так же, как и REMOVE-IF-NOT , она, в действительности, не изменяет список, который был ей передан в качестве параметра, тем не менее, сохраняя результат обратно в *db* , delete-rows [38] фактически изменяет содержимое базы данных [39].

<p>Избавление от дублирующего кода и большой выигрыш</p>

До сих пор весь код базы данных, обеспечивающий операции INSERT , SELECT , UPDATE и DELETE , если не считать интерфейс командной строки для добавления новых записей и распечатки содержимого базы, укладывался в немногим более пятидесяти строк. Целиком [40].

Всё еще существует некоторое раздражающее дублирование кода. И, оказывается, вы можете избавиться от этого дублирования, в то же время сделав код более гибким. Дублирование, о котором я говорю, находится в функции where . Тело функции where набор условий для каждого поля, таких, как это:

(if title (equal (getf cd :title) title) t)

Сейчас это не так плохо, но, как и во многих случаях дублирования кода, за это всегда приходится платить одну цену: если вы хотите изменить работу этого кода, вам нужно изменять множество копий. И если вы изменили поля в CD, вам придётся добавить или удалить условия для where . update страдает точно таким же дублированием. Это, несомненно, плохо, так как весь смысл функции where заключается в динамической генерации куска кода, проверяющего нужные нам значения; почему она должна производить работу во время выполнения, каждый раз проверяя, было ли ей передано значение title ?

Представьте, что вы попытались оптимизировать этот код и обнаружили, что много времени тратится на проверку того, заданы ли значения title и оставшиеся ключ-параметры [41]. Если вы на самом деле хотите избавиться от этих проверок во время выполнения, вы можете просмотреть программу и найти все места, где вы вызываете where , и посмотреть, какие аргументы вы передаёте. Затем вы можете заменить каждый вызов where анонимной функцией, выполняющей только необходимое вычисления. Например, если вы нашли такой кусок кода:

(select (where :title "Give Us a Break" :ripped t))

вы можете заменить его на такой:

(select

#'(lambda (cd)

(and (equal (getf cd :title) "Give Us a Break")

(equal (getf cd :ripped) t))))

Заметьте, что анонимная функция отличается от той, что возвращает where ; мы не пытаемся сохранить вызов where , а обеспечиваем большую производительность функции выбора. Эта анонимная функция имеет условия только для нужных нам полей, и она не производит дополнительной работы, в отличие от функции, которую может возвратить where .

Вы можете представить себе, что значит пройтись по всему исходному тексту исправить все вызовы where таким образом. И вы можете представить, насколько это болезненно. Если бы этого было достаточно, и это было бы очень важно, вероятно, стоило бы написать некоторого рода препроцессор, который бы конвертировал вызовы where в то, что вы бы написали вручную.

Средство Lisp, позволяющее делать это очень просто, называется системой макросов. Подчеркиваю, что макрос в Common Lisp не имеет, в сущности, ничего общего (кроме имени) с текстовыми макросами из C и C++. В то время, как препроцессор C оперирует текстовой подстановкой и не знает ничего о стуктуре C и C++, в Lisp макрос, в сущности, является генератором кода, который автоматически запускается для вас компилятором [42]. Когда выражение на Lisp содержит вызов макроса, компилятор Lisp, вместо вычисления аргументов и передачи их в функцию, передает аргументы, не вычисляя их, в код макроса, который, в свою очередь, возвращает новое выражение на Lisp, которое затем вычисляется в месте исходного вызова макроса.

Я начну с простого и глупого примера и затем покажу, как вы можете заменить функцию where макросом where . Перед тем, как я напишу этот макрос-пример, мне необходимо представить вам одну новую функцию: REVERSE принимает аргумент в виде списка и возвращает новый список, который является обратным к исходному. Таким образом, (reverse '(1 2 3)) вернёт (3 2 1) . Теперь попробуем создать макрос.

(defmacro backwards (expr)

(reverse expr))

Главное синтаксическое отличие между функцией и макросом заключается в том, что макрос определяется ключевым словом DEFMACRO , а не DEFUN . После ключевого слова в определении макроса, подобно определению функции, следует имя, список параметров и тело с выражениями. Однако макросы действуют совершенно по-другому. Вы можете использовать макрос так:

CL-USER> (backwards ("hello, world" t format))

hello, world

NIL

Как это работает? Когда REPL начинает вычислять выражение backwards , он обнаруживает, что backwards имя макроса. Поэтому он не вычисляет выражение ("hello, world" t format) , что очень хорошо, так как это некорректная для Lisp структура. Далее он передаёт этот список коду backwards . Код backwards передает список в функцию REVERSE , которая возвращает список (format t "hello, world") . Затем backwards передает это значение обратно REPL, который подставляет его на место исходного выражения.

Макрос backwards , таким образом, определяет новый язык, во многом похожий на Lisp только задом наперёд который вы можете вставлять в свой код в любой момент, просто обернув обратное выражение на Lisp в вызов макроса backwards . И в скомпилированной программе на Lisp этот новый язык покажет такую же производительность, как и обычный Lisp, потому что весь код в макросе код, сгенерированный в новом выражении выполняется во время компиляции. Другими словами, компилятор сгенерирует один и тот же код, независимо от того, напишете вы (backwards ("hello, world" t format)) или (format t "hello, world") .

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

(equal (getf cd field) value)

Давайте напишем функцию, которая, получив имя поля и некоторое значение, возвращает такое выражение. Так как выражение это просто список, вы можете подумать, что возможно написать что-нибудь вроде:

(defun make-comparison-expr (field value) ; неправильно

(list equal (list getf cd field) value))

Однако здесь имеется небольшой нюанс: как вы знаете, когда Lisp обнаруживает просто имя вроде field или value , а не первый элемент списка, он полагает, что это имя переменной, и пытается получить ее значение. Это нормально для field и value ; это именно то, что нужно. Но он будет обращаться к equal , getf и cd таким же образом, а это в нашем случае нежелательно. Вы, однако, знаете также, как не позволить Lisp пытаться вычислить структуру: поместить перед ней одиночную кавычку ( '). Таким образом, если вы напишете функцию make-comparison-expr вот так, она сделает то, что вам нужно:

(defun make-comparison-expr (field value)

(list 'equal (list 'getf 'cd field) value))

Вы можете проверить её работу в REPL:

CL-USER> (make-comparison-expr :rating 10)

(EQUAL (GETF CD :RATING) 10)

CL-USER> (make-comparison-expr :title "Give Us a Break")

(EQUAL (GETF CD :TITLE) "Give Us a Break")

Но, оказывается, существует лучший способ сделать это. То, что вам действительно нужно, это иметь возможность написать выражение, которое в большинстве случаев не вычисляется, и затем каким-либо образом выбирать некоторые выражения, которые вы хотите вычислить. И, конечно же, такой механизм существует. Обратная кавычка ( ` ) перед выражением запрещает его вычисление, точно так же, как и прямая одиночная кавычка.

CL-USER> `(1 2 3)

(1 2 3)

CL-USER> '(1 2 3)

(1 2 3)

Однако в выражении с обратной кавычкой любое подвыражение, перед которым стоит запятая, вычисляется. Обратите внимание на влияние запятой во втором выражении:

`(1 2 (+ 1 2)) ==> (1 2 (+ 1 2))

`(1 2 ,(+ 1 2)) ==> (1 2 3)

Используя обратную кавычку, вы можете переписать функцию make-comparison-expr следующим образом:

(defun make-comparison-expr (field value)

`(equal (getf cd ,field) ,value))

Теперь, если вы посмотрите на оптимизированную вручную функцию выбора, вы увидите, что тело функции состоит из одного оператора сравнения для каждой пары поле/значение, обернутое в выражение AND . На мгновение предположим, что вам нужно расположить аргументы таким образом, чтобы передать их макросу where единым списком. Вам понадобится функция, которая принимает аргументы этого списка попарно и сохраняет результаты выполнения вызова make-comparison-expr для каждой пары. Чтобы реализовать эту функцию, вы можете воспользоваться мощным макросом LOOP .

(defun make-comparisons-list (fields)

(loop while fields

collecting (make-comparison-expr (pop fields) (pop fields))))

Полное описание макроса LOOP отложим до 22 главы, а сейчас заметим, что выражение LOOP выполняет именно то, что требуется: оно циклично проходит по всем элементам в списке fields , каждый раз возвращая по два элемента, передаёт их в make-comparison-expr и сохраняет возвращаемые результаты, чтобы их вернуть при выходе из цикла. Макрос POP выполняет операцию, обратную операции, выполняемой макросом PUSH , который вы использовали для добавления записей в *db* .

Теперь вам нужно просто обернуть список, возвращаемый функцией make-comparison-list в AND и анонимную функцию, которую вы можете реализовать прямо в макросе where . Это просто: используйте обратную кавычку, чтобы создать шаблон, который будет заполнен значениями функции make-comparison-list .

(defmacro where (&rest clauses)

`#'(lambda (cd) (and ,@(make-comparisons-list clauses))))

Этот макрос использует вариацию , (а именно, ,@ ) перед вызовом make-comparison-list . Сочетание ,@ "вклеивает" значение следующего за ним выражения, которое должно возвращать список, во "внешний" список.

`(and ,(list 1 2 3)) ==> (AND (1 2 3))

`(and ,@(list 1 2 3)) ==> (AND 1 2 3)

Вы также можете использовать ,@ для "вклейки" элементов в середину списка:

`(and ,@(list 1 2 3) 4) ==> (AND 1 2 3 4)

Другая важная особенность макроса where использование &rest в списке аргументов. Так же, как и &key , &rest изменяет способ разбора аргументов. Если в списке параметров обнаруживается &rest , функция или макрос могут принимать произвольное число аргументов, которые собираются в единый список, становящийся значением переменной, имя которой следует за &rest . Итак, если вы вызовите where так:

(where :title "Give Us a Break" :ripped t)

переменная clauses будет содержать список:

(:title "Give Us a Break" :ripped t)

Этот список передается функции make-comparisons-list , которая возвращает список выражений сравнения. С помощью функции MACROEXPAND-1 вы можете точно видеть, какой код будет сгенерирован where . Если вы передадите в MACROEXPAND-1 форму, являющуюся вызовом макроса, она вызовет макрос с заданными аргументами и вернёт его развёрнутый вид. Итак, вы можете проверить предыдущий вызов where следующим образом:

CL-USER> (macroexpand-1 '(where :title "Give Us a Break" :ripped t))

#'(LAMBDA (CD)

(AND (EQUAL (GETF CD :TITLE) "Give Us a Break")

(EQUAL (GETF CD :RIPPED) T)))

T

Выглядит неплохо. Теперь попробуем испытать макрос в действии:

CL-USER> (select (where :title "Give Us a Break" :ripped t))

((:TITLE "Give Us a Break" :ARTIST "Limpopo" :RATING 10 :RIPPED T))

Работает. И макрос where с его двумя функциями-помощниками оказался на одну строку короче, чем старая функция where . И, что самое главное, where больше не привязана к конкретным полям наших записей о CD.

<p>Об упаковке</p>

Случилась интересная вещь. Вы избавились от дублирования и сделали код одновременно более производительным и универсальным. Так часто бывает, если правильно выбрать макрос. Это имеет смысл, потому что макрос это ещё один механизм создания абстракций абстракций на синтаксическом уровне, а абстракции это, по определению, более короткий путь для выражения подразумеваемых сущностей. Сейчас код мини-базы данных, который относится к CD и полям, его описывающим, находится только в функциях make-cd , prompt-for-cd и add-cd . Фактически, наш новый макрос будет работать с любой базой данных, основанной на списке свойств.

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

Целью этой главы являлось быстрое введение в лишь малую часть возможностей Lisp и демонстрация того, как они используются для написания кода, чуть более интересного, чем "Hello, world" . В следующей главе мы начнём более систематический обзор Lisp.

4
<p>4. Синтаксис и семантика</p>

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

<p>Почему так много скобок?</p>

Синтаксис Lisp немного отличается от синтаксиса языков, произошедших от Algol. Две наиболее очевидные черты это обширное использование скобок и префиксная нотация. Именно эти черты отпугивают многих людей. Очернители Lisp склонны описывать его синтаксис как "непонятный" и "раздражающий". Название "Lisp", по их словам, должно обозначать "Множество Раздражающих Ненужных Скобок" (Lots of Irritating Superfluous Parentheses). С другой стороны, люди, использующие Lisp, склонны рассматривать синтаксис Lisp как одно из главных его достоинств. Как может быть то, что так не нравится одной группе, быть предметом восхищения другой?

Я не смогу действительно объяснить вам все состояние дел с синтаксисом Lisp, пока не расскажу немного подробней о макросах Lisp. Но я могу начать с предыстории, которая наводит на мысль, что имеет смысл откинуть предрассудки и попытаться разобраться, что к чему: когда John McCarthy изобрел Lisp, он намеревался реализовать его в более Algol-подобном синтаксисе, который он называл M-выражения. Однако он так не и сделал этого. Причину он объясняет в своей статье "История Lisp" [43].

Проект по точному определению М-выражений и их компиляции или, хотя бы, трансляции их в S-выражения не был ни завершен, ни явно заброшен. Он просто был отложен на неопределенное будущее, а тем временем появилось новое поколение программистов, которые предпочитали S-выражения любой Fortran- или Algol-подобной нотации, которая только может быть выдумана.

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

<p>Разделение черного ящика</p>

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

В большинстве языков программирования процессор языка (интерпретатор или компилятор) работает как черный ящик: вы засовываете последовательность символов, представляющих собой текст программы, в черный ящик и он (в зависимости от того, является ли он интерпретатором или компилятором) либо выполняет указанные инструкции, либо создает скомпилированную версию программы, которая выполняет инструкции после запуска.

Внутри черного ящика, конечно, процессоры языка обычно разделяются на подсистемы, каждая из которых ответственна за одну из частей задачи трансляции текста программы в последовательность инструкций или объектный код. Типичное разделение это разбиение работы процессора на три фазы, каждая из которых предоставляет данные следующей: лексический анализатор разделяет поток знаков на лексемы и передает их синтаксическому анализатору, который строит дерево, представляющее выражения программы в соответствии с грамматикой языка. Это дерево (называемое абстрактным синтаксическим деревом, AST) далее передается процедуре вычисления, которая либо напрямую интерпретирует его, либо компилирует его в какой-то другой язык; например в машинный код. Так как языковой процессор является черным ящиком, то структуры данных, используемые процессором, такие как лексемы или абстрактные синтаксические деревья, интересуют только конструкторов реализации языка.

В Common Lisp разбивка на фазы осуществлена немного иначе, с последствиями как для конструкторов реализации, так и для определения языка. Вместо одного черного ящика, который осуществляет переход от текста программы к ее поведению за один шаг, Common Lisp определяет два черных ящика, первый из которых транслирует текст в объекты Lisp, а другой реализует семантику языка в терминах этих объектов. Первый ящик называется процедурой чтения, а второй - процедурой вычисления [44].

Каждый черный ящик определяет один уровень синтаксиса. Процедура чтения определяет, как строки знаков могут транслироваться в объекты, называемые s-выражениями [45]. Так как синтаксис s-выражений включает синтаксис для списков произвольных объектов, включая другие списки, s-выражения могут представлять произвольные древовидные выражения ( tree expressions ), очень похожие на абстрактные синтаксические деревья, генерируемые синтаксическими анализаторами не-Lisp языков.

В свою очередь, процедура вычисления определяет синтаксис форм Lisp, которые могут быть построены из s-выражений. Не все s-выражения являются допустимыми формами Lisp также как и не все последовательности знаков являются допустимыми s-выражениями. Например, и (foo 1 2) , и ("foo" 1 2) являются s-выражениями, но только первое может быть формой Lisp, так как список, который начинается со строки, не является формой Lisp.

Это разделение черного ящика имеет несколько следствий. Одно из них состоит в том, что вы можете использовать s-выражения, как вы видели в главе 3, в качестве внешнего формата для данных, не являющихся исходным кодом, используя READ для их чтения и PRINT для их записи [46]. Другое следствие состоит в том, что так как семантика языка определена в терминах деревьев объектов, а не в терминах строк знаков, то генерировать код внутри языка становится легче, чем это можно было бы сделать, если бы код генерировался как текст. Генерирование кода полностью с нуля не намного легче: и построение списков, и построения строк являются примерно одинаковыми по сложности работами. Однако реальный выигрыш в том, что вы можете генерировать код, манипулируя существующими данными. Это является базой для макросов Lisp, которые я опишу гораздо подробнее в будущих главах. Сейчас я сфокусируюсь на двух уровнях синтаксиса, определенных Common Lisp: это синтаксис s-выражений, понимаемый процедурой чтения, и синтаксис форм Lisp, понимаемый процедурой вычисления.

<p>S-выражения</p>

Базовыми элементами s-выражения являются списки и атомы. Списки ограничиваются скобками и могут содержать любое число разделенных пробелами элементов. Все, что не список, является атомом [47]. Элементами списков в свою очередь также являются s-выражения (другими словами, атомы или вложенные списки). Комментарии (которые, строго говоря, не являются s-выражениями) начинаются с точки с запятой, распространяются до конца строки, и трактуются как пробел.

И это почти все. Так как списки синтаксически просты, то те оставшиеся синтаксические правила, которые вам необходимо знать, касаются только различных типов атомов. В этой секции я опишу правила для большинства часто используемых типов атомов: чисел, строк и имен. После этого, я расскажу как s-выражения, составленные из этих элементов, могут быть вычислены как формы Lisp.

С числами все довольно очевидно: любая последовательность цифр (возможно, начинающаяся со знака (+ или -), содержащая десятичную точку или знак деления, и, возможно, заканчивающаяся меткой показателя степени) трактуется как число. Например:

123 ; целое число "сто двадцать три"

3/7 ; отношение "три седьмых"

1.0 ; число с плавающей точкой "один" с точностью, заданной по умолчанию

1.0e0 ; другой способ записать то же самое число с плавающей точкой

1.0d0 ; число с плавающей точкой "один" двойной точности

1.0e-4 ; эквивалент с плавающей точкой числа "одна десятитысячная"

+42 ; целое число "сорок два"

-42 ; целое отрицательное число "минус сорок два"

-1/4 ; отношение "минус одна четвертая"

-2/8 ; другой способ записать то же отношение

246/2 ; другой способ записать целое "сто двадцать три"

Эти различные формы представляют различные типы чисел: целые, рациональные, числа с плавающей точкой. Lisp также поддерживает комплексные числа, которые имеют свою собственную нотацию, и которые мы рассмотрим в главе 10.

Как показывают некоторые из этих примеров, вы можете задать одно и то же число множеством различных способов. Но независимо от того, как вы запишите их, все рациональные (целые и отношения) внутри Lisp представляются в "упрощенной" форме. Другими словами, объекты, которые представляют числа -2/8 и 246/2, не отличаются от объектов, которые представляют числа -1/4 и 123. Таким же образом, 1.0 и 1.0e0 просто два разных способа записать одно число. С другой стороны, 1.0, 1.0d0 и 1 могут представлять различные объекты, так как различные представления чисел с плавающей точкой и целых чисел являются различными типами. Мы рассмотрим детали характеристик различных типов чисел в главе 10.

Строковые литералы, как вы видели в предыдущей главе, заключаются в двойные кавычки. Внутри строки обратный слеш (\) экранирует следующий знак, что вызывает включение этого знака в строку "как есть". Только два знака должны быть экранированы в строке: двойная кавычка и сам обратный слеш. Все остальные знаки могут быть включены в строковый литерал без экранирования, не обращая внимания на их значение вне строки. Несколько примеров строковых литералов:

"foo" ; строка, содержащая знаки 'f', 'o' и 'o'.

"fo\o" ; такая же строка.

"fo\\o" ; строка, содержащая знаки 'f', 'o', '\' и 'o'.

"fo\"o" ; строка, содержащая знаки 'f', 'o', '"' и 'o'.

Имена, использующиеся в программах на Lisp, такие как FORMAT , hello-world и *db* представляются объектами, называющимися символами . Процедура чтения ничего не знает о том, как данное имя будет использоваться является ли оно именем переменной, функции или чем-то еще. Она просто читает последовательность знаков и создает объект, представляющий имя [48]. Почти любой знак может входить в имя. Однако, это не может быть пробельный знак, так как пробелом разделяются элементы списка. Цифры могут входить в имена, если имя целиком не сможет интерпретироваться как число. Схожим образом, имена могут содержать точки, но процедура чтения не может прочитать имя, состоящее только из точек. Существует десять знаков, которые не могут входить в имена, так как предназначены для других синтаксических целей: открывающая и закрывающая скобки, двойные и одинарные кавычки, обратный апостроф, запятая, двоеточие, точка с запятой, обратный слеш и вертикальная черта. Но даже эти знаки могут входить в имена, если их экранировать обратным слешем или окружить часть имени, содержащую знаки, которые нужно экранировать, с помощью вертикальных линий.

Две важные характерные черты того, каким образом процедура чтения переводит имена в символьные объекты, касаются того, как она трактует регистр букв в именах и как она обеспечивает то, чтобы одинаковые имена всегда читались как одинаковые символы. Во время чтения имен процедура чтения конвертирует все неэкранированные знаки в именах в их эквивалент в верхнем регистре. Таким образом, процедура чтения прочитает foo , Foo и FOO как одинаковый символ: FOO . Однако, \f\o\o и |foo| оба будут прочитаны как foo, что будет отличным от символа FOO объектом. Это как раз и является причиной, почему при определении функции в REPL, он печатает имя функции, преобразованное к верхнему регистру. Сейчас стандартным стилем является написание кода в нижнем регистре, позволяя процедуре чтения преобразовывать имена к верхнему [49].

Чтобы быть уверенным в том, что одно и то же текстовое имя всегда читается как один и тот же символ, процедура чтения хранит все символы после того, как она прочитала имя и преобразовала его к верхнему регистру, процедура чтения ищет в таблице, называемой пакетом ( package ), символ с таким же именем. Если она не может найти такой, то она создает новый символ и добавляет его к таблице. Иначе она возвращает символ, уже хранящийся в таблице. Таким образом, где бы одно и то же имя не появлялось в любых s-выражениях, оно будет представлено одним и тем же объектом [50].

Так как имена в Lisp могут содержать намного большее множество знаков, чем в языках, произошедших от Algol, в Lisp существуют определенные соглашения по именованию, такие как использование дефисов в именах наподобие hello-world . Другое важное соглашение состоит в том, что глобальным переменным дают имена, начинающиеся и заканчивающиеся знаком *. Подобным образом, константам дают имена, начинающиеся и заканчивающиеся знаком +. Также некоторые программисты называют очень низкоуровневые функции именами, начинающимися с % или даже %%. Имена, определенные в стандарте языка, используют только алфавитные знаки (A-Z), а также *, +, -, /, 1, 2, <, =, >, &.

Синтаксис для списков, чисел, строк и символов описывает большую часть Lisp программ. Другие правила описывают нотацию для векторных литералов, отдельных знаков, массивов, которые я опишу в главах 10 и 11, когда мы будем говорить об этих типах данных. Сейчас главным является понимание того, как комбинируются числа, строки и символы с разделенными скобками списками для построения s-выражений, представляющих произвольные деревья объектов. Несколько простых примеров:

x ; символ X

() ; пустой список

(1 2 3) ; список из трех элементов

("foo" "bar") ; список из двух строк

(x y z) ; список из трех символов

(x 1 "foo") ; список из символа, числа и строки

(+ (* 2 3) 4) ; список из символа, списка и числа

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

(defun hello-world ()

(format t "hello, world"))

<p>S-выражения как формы Lisp</p>

После того, как процедура чтения преобразовывает текст в s-выражения, эти s-выражения могут быть вычислены как код Lisp. Точнее некоторые из них могут не каждое s-выражение, которое процедура чтения может прочитать, обязательно может быть вычислено как код Lisp. Правила вычислений Common Lisp определяют второй уровень синтаксиса, который определяет, какие s-выражения могут трактоваться как формы Lisp [51]. Синтаксические правила на этом уровне очень просты. Любой атом (не список или пустой список) является допустимой формой Lisp, а также любой список, который содержит символ в качестве своего первого элемента, также является допустимой формой Lisp [52].

Конечно, интересным является не синтаксис форм Lisp, а то, как эти формы вычисляются. Для целей дальнейшего обсуждения вам достаточно думать о процедуре вычисления как о функции, которая получает в качестве аргумента синтаксически правильную форму Lisp и возвращает значение, которое мы можем назвать значением ( value ) формы. Конечно, когда процедура вычисления является компилятором, это является небольшим упрощением в этом случае процедура вычисления получает выражение и генерирует код, который, будучи запущенным, вычислит соответствующее значение. Но это упрощение позволит мне описать семантику Common Lisp в терминах того, как различные типы форм Lisp вычисляются с помощью этой воображаемой функции.

Простейшие формы Lisp, атомы, могут быть разделены на две категории: символы и все остальное. Символ, вычисляемый как форма, трактуется как имя переменной и вычисляется в ее текущее значение [53]. Я обсужу в главе 6 как переменные получают свои значения впервые. Также следует заметить, что некоторые "переменные" являются старым программистским оксюмороном: "константными переменными". Например, символ PI именует константную переменную, чье значение число с плавающей точкой, являющееся наиболее близкой аппроксимацией математической константы π.

Все остальные атомы (числа и строки) являются типом объектов, который вы уже рассмотрели это самовычисляемые объекты ( self-evaluating objects ). Это означает, что когда выражение передается в воображаемую функцию вычисления, оно просто возвращается. Вы видели примеры самовычисляемости объектов в главе 2, когда набирали 10 и "hello, world" в REPL.

Символы также могут быть самовычисляемыми в том смысле, что переменной, которую именует такой символ, может быть присвоено значение самого этого символа. Две важные константы определены таким образом: T и NIL , стандартные истинное и ложное значения. Я обсужу их роль как логических выражений в секции "Правда, ложь и равенство".

Еще один класс самовычисляемых символов это символы-ключи ( keyword symbols ) символы, чьи имена начинаются с :. Когда процедура чтения обрабатывает такое имя, она автоматически определяет константную переменную с таким именем и таким символом в качестве значения.

Всё становится гораздо интереснее при рассмотрении того, как вычисляются списки. Все допустимые формы списков начинаются с символа, но существуют три разновидности форм списков, которые вычисляются тремя различными способами. Для определения того, какую разновидность формы представляет из себя данный список, процедура вычисления должна определить чем является первый символ списка: именем функции, макросом или специальным оператором. Если символ еще не был определен (такое может быть в случае, если вы компилируете код, который содержит ссылки на функции, которые будут определены позднее) предполагается, что он является именем функции [54]. Я буду ссылаться на эти три разновидности форм как на формы вызова функции ( function call forms ), формы макросов ( macro forms ) и специальные формы ( special forms ).

<p>Вызовы функций</p>

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

(function-name argument*)

Таким образом, следующее выражение вычисляется путем первоначального вычисления 1, затем 2, а затем передачи результатов вычислений в функцию +, которая возвращает 3:

(+ 1 2)

Более сложное выражение, такое как следующее, вычисляется схожим образом за исключением того, что вычисление аргументов (+ 1 2) и (- 3 4) влечет за собой вычисление аргументов этих форм и применение соответствующих функций к ним:

(* (+ 1 2) (- 3 4))

В итоге, значения 3 и -1 передаются в функцию *, которая возвращает -3.

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

<p>Специальные операторы</p>

Нужно сказать, что не все операции могут быть определены как функции. Так как все аргументы функции вычисляются перед ее вызовом, не существует возможности написать функцию, которая ведет себя как оператор IF , который вы использовали в главе 3. Для того, чтобы увидеть почему, рассмотрим такую форму:

(if x (format t "yes") (format t "no"))

Если IF является функцией, процедура вычисления будет вычислять аргументы выражения слева направо. Символ x будет вычислен как переменная, возвращающая свое значение; затем как вызов функции будет вычислена (format t "yes") , возвращающая NIL после печати "yes" на стандартный вывод; и затем будет вычислена (format t "no"), печатающая "no" и возвращающая NIL . Только после того, как эти три выражения будут вычислены, их результаты будут переданы в IF , слишком поздно для того, чтобы проконтролировать, какое из двух выражений FORMAT будет вычислено.

Для решения этой проблемы Common Lisp определяет небольшое количество так называемых специальных операторов (и один из них IF ), которые делают те вещи, которые функции сделать не могут. Всего их 25, но только малая их часть напрямую используется в ежедневном программировании [55].

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

Правило для IF очень просто: вычисление первого выражения. Если оно вычисляется не в NIL , то вычисляется следующее выражение и возвращается его результат. Иначе возвращается значение вычисления третьего выражения или NIL , если третье выражение не задано. Другими словами, базовая форма выражения IF следующая:

(if test-form then-form [ else-form ])

test-form вычисляется всегда, а затем только одна из then-form и else-form .

Еще более простой специальный оператор - это QUOTE , который получает одно выражение как аргумент и просто возвращает его не вычисляя. Например, следующая форма вычисляется в список (+ 1 2) , а не в значение 3:

(quote (+ 1 2))

Этот список не отличается ни от какого другого, вы можете манипулировать им также, как и любым другим, который вы можете создать с помощью функции LIST [56]

QUOTE используется достаточно часто, поэтому для него в процедуру чтения был встроен специальный синтаксис. Вместо написания такого:

(quote (+ 1 2))

вы можете написать это:

'(+ 1 2)

Этот синтаксис является небольшим расширением синтаксиса s-выражений, понимаемым процедурой чтения. С этой точки зрения для процедуры вычисления оба этих выражения выглядят одинаково: список, чей первый элемент является символом QUOTE , а второй элемент список (+ 1 2) [57].

В общем, специальные операторы реализуют возможности языка, которые требуют специальной обработки процедурой вычисления. Например, некоторые специальные операторы манипулируют окружением, в котором вычисляются другие формы. Один из них, который я обсужу детально в главе 6, - LET , который используется для создания новой привязки переменной ( variable binding ). Следующая форма вычисляется в 10, так как второй x вычисляется в окружении, где он именует переменную, связанную оператором LET со значением 10:

(let ((x 10)) x)

<p>Макросы</p>

В то время как специальные операторы расширяют синтаксис Common Lisp, выходя за пределы того, что может быть выражено простыми вызовами функций, множество специальных операторов ограничено стандартом языка. С другой стороны, макросы дают пользователям языка способ расширения его синтаксиса. Как вы увидели в главе 3, макрос это функция, которая получает в качестве аргументов s-выражения и возвращает форму Lisp, которая затем вычисляется на месте формы макроса. Вычисление формы макроса происходит в две фазы: сначала элементы формы макроса передаются, не вычисляясь, в функцию макроса, а затем форма, возвращенная функцией макроса (называемая ее раскрытием ( expansion )), вычисляется в соответствии с обычными правилами вычисления.

Очень важно понимать обе фазы вычисления форм макросов. Очень легко запутаться когда вы печатаете выражения в REPL, так как эти две фазы происходят одна за одной и значение второй фазы немедленно возвращается. Но, когда код Lisp компилируется, эти две фазы выполняются в разное время, и очень важно понимать, что и когда происходит. Например, когда вы компилируете весь файл с исходным кодом с помощью функции COMPILE-FILE , все формы макросов в файле рекурсивно раскрываются, пока код не станет содержать ничего кроме форм вызова функций и специальных форм. Этот не содержащий макросов код затем компилируется в файл FASL, который функция LOAD знает как загрузить. Скомпилированный код, однако, не выполняется пока файл не будет загружен. Так как макросы генерируют свое расширение во время компиляции, они могут проделывать довольно большой объем работы, генерируя свои раскрытия, без платы за это во время загрузки файла или при вызове функций, определенных в этом файле.

Так как процедура вычисления не вычисляет элементы формы макроса перед передачей их в функцию макроса, они не обязательно должны быть правильными формами Lisp. Каждый макрос назначает смысл s-выражениям, используемым в форме этого макроса (macro form), посредством того, как он использует эти s-выражения для генерации своего расширения. Другими словами, каждый макрос определяет свой собственный локальный синтаксис. Например, макрос переворачивания списка задом наперед из главы 3 определяет синтаксис, в котором выражение является допустимой перевернутой формой если ее список, будучи перевернутым, является допустимой формой Lisp.

Я расскажу больше о макросах в этой книге. А сейчас вам важно понимать, что макросы, несмотря на то, что синтаксически похожи на вызовы функции, служат иной цели, предоставляя добавочный уровень к компилятору [58].

<p>Истина, Ложь и Равенство</p>

Оставшейся частью базовых знаний, которые вам необходимо получить, являются понятия истины, лжи и равенства объектов в Common Lisp. Понятия истины и лжи очень просты: символ NIL является единственным ложным значением, а все остальное является истиной. Символ T является каноническим истинным значением и может быть использован когда вам нужно вернуть не- NIL значение, но само значение не важно. Единственной хитростью является то, что NIL также является единственным объектом, который одновременно является и атомом и списком: вдобавок к представлению ложного значения он также используется для представления пустого списка [59]. Эта равнозначность NIL и пустого списка встроена в процедуру чтения: если процедура чтения видит () , она считывает это как символ NIL . Обе записи полностью взаимозаменяемые. И так как NIL , как я уже упоминал раньше, является именем константной переменной, значением которой является символ NIL , то выражения nil , () , 'nil и '() вычисляются в одинаковый объект: unquoted формы вычисляются как ссылка на константную переменную, значение которой символ NIL , а quoted формы, при помощи оператора QUOTE , вычисляются в символ NIL напрямую. По этим же причинам, и t и 't будут вычислены в одинаковый объект: символ T .

Использование фраз, таких как "то же самое", конечно рождает вопрос о том, что для двух значений значит "то же самое". Как вы увидите в следующих главах, Common Lisp предоставляет ряд типо-зависимых предикатов равенства: = используется для сравнения чисел; CHAR= для сравнения знаков и т.д. В этой секции мы рассмотрим четыре "общих" ("generic") предиката равенства функции, которым могут быть переданы два Lisp-объекта, и которые возвратят истину, если эти объекты эквивалентны, и ложь в противном случае. Вот они в порядке ослабления понятия "различности": EQ , EQL , EQUAL , и EQUALP .

EQ проверяет "идентичность объектов": она возвращает истинное значение если два объекта идентичны. К сожалению, понятие идентичности таких объектов, как числа и знаки, зависит от того, как эти типы данных реализованы в конкретной реализации Lisp. Таким образом, EQ может считать два числа или два знака с одинаковым значением, как эквивалентными, так и нет. Стандарт языка оставляет реализациям достаточную свободу действий в этом вопросе, что приводит к тому, что выражение (eq 3 3) может вполне законно вычисляться как в истинное, так и в ложное значение. Таким же образом (eq x x) может вычисляться как в истинное, так и в ложное значение в различных реализациях если значением x является число или знак.

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

Поэтому, Common Lisp определяет EQL , работающую аналогично EQ , за исключением того, что она также гарантирует рассмотрение эквивалентными двух объектов одного класса, представляющих одинаковое числовое или знаковое (character) значение. Поэтому (eql 1 1) гарантировано будет истиной. А (eql 1 1.0) гарантировано будет ложью, так как целое значение 1 и значение с плавающей точкой 1.0 являются представителями различных классов.

Существуют два лагеря по отношению к вопросу где использовать EQ и где использовать EQL : сторонники "когда возможно всегда используйте EQ " убеждают вас использовать EQ , когда вы уверены, что не будете сравнивать числа или знаки так как: a) это способ указать, что вы не собираетесь сравнивать числа и знаки; b) это будет немного эффективней, так как EQ не нужно проверять, являются ли ее аргументы числами или знаками.

Сторонники "всегда используйте EQL " советуют вам никогда не использовать EQ , так как (а) потенциальный выигрыш в ясности теряется, так как каждый раз, когда кто-либо будет читать ваш код (включая вас) и увидит EQ , он должен будет остановиться и проверить, корректно ли эта функция используется (другими словами, проверить, что она никогда не вызывается для сравнения цифр или знаков) и (b) различие в эффективности между EQ и EQL очень мало по сравнению с производительностью в действительно узких местах.

Код в этой книге написан в стиле "всегда используйте EQL " [60].

Другие два предиката равенства EQUAL и EQUALP являются общими в том смысле, что они могут оперировать всеми типами объектов, но они не настолько фундаментальные, как EQ или EQL . Каждый из них определяет несколько более слабое понятие "различности", чем EQL , позволяя другим объектам считаться эквивалентным. Нет ничего особенного в тех конкретных понятиях эквивалентности, что реализуют эти функции, за исключением того, что они оказались полезными Lisp-программистам прошлого. Если эти предикаты не подходят вам, вы всегда можете определить свой собственный предикат для сравнения объектов других типов нужным вам способом.

EQUAL ослабляет понятие "различности" между EQL , считая списки эквивалентными, если они рекурсивно, согласно тому же EQUAL , имеют одинаковую структуру и содержимое. EQUAL также считает строки эквивалентными, если они содержат одинаковые знаки. EQUAL также ослабляет понятие "различности" по сравнению с EQL для битовых векторов (bit vectors) и путей двух типах, о которых я расскажу в следующих главах. Для всех остальных типов он аналогичен EQL .

EQUALP аналогична EQUAL за исключением еще большего ослабления понятия "различности". EQUALP считает две строки эквивалентными, если они имеют одинаковые знаки, игнорируя разницу в регистре. Два знака также считаются эквивалентными, если они отличается только регистром. Числа эквивалентны по EQUALP , если они представляют одинаковое математическое значение. Например, (equalp 1 1.0) вернет истину. Списки, элементы которых попарно эквивалентны по EQUALP , считаются эквивалентными; подобным же образом массивы с элементами, эквивалентными по EQUALP , также считаются эквивалентными. Как и в случае с EQUAL , существует несколько других типов данных, которые я пока не рассмотрел, для которых EQUALP может рассмотреть два объекта эквивалентными, в то время как EQL и EQUAL будут считать их различными. Для всех остальных типов данных EQUALP аналогична EQL .

<p>Форматирование кода Lisp</p>

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

(some-function arg-with-a-long-name

another-arg-with-an-even-longer-name)

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

(defun print-list (list)

(dolist (i list)

(format t "item: ~a~%" i)))

Однако, вам не нужно сильно беспокоиться на счет этих правил, так как хорошая среда Lisp, такая как SLIME, возьмет эту заботу на себя. Фактически, одним из преимуществ регулярного синтаксиса Lisp является то, что программному обеспечению, такому как текстовые редакторы, очень легко расставлять отступы. Так как расстановка отступов нужна для отражения структуры кода, а структура определяется скобками, легко позволить редактору расставить отступы вместо вас.

В SLIME нажатие Tab в начале каждой строки приводит к тому, что строка будет правильно выровнена; также вы можете перевыровнять целое выражение, поставив курсор на открывающую скобку и набрав C-M-q. Или вы можете перевыровнять все тело функции, набрав C-c M-q, находясь где угодно в теле функции.

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

(defun foo ()

(if (test)

(do-one-thing)

(do-another-thing)))

Теперь предположим, что вы случайно не поставили закрывающую скобку после test . Поскольку вы не обеспокоены подсчетом скобок, вы просто добавите ещё одну в конец формы DEFUN , получив следующий код:

(defun foo ()

(if (test

(do-one-thing)

(do-another-thing))))

Однако, если вы выравнивали код, нажимая Tab в начале каждой строки, вы не получите вышеприведенный код. Вместо него вы получите это:

(defun foo ()

(if (test

(do-one-thing)

(do-another-thing))))

Выравнивание веток then и else , перенесенных под условие вместо того, чтобы находиться чуть правее if , немедленно говорит нам, что что-то не так.

Другое важное правило форматирования заключается в том, что закрывающие скобки всегда помещаются в той же строке, что и последний элемент списка, который они закрывают. Так что не пишите так:

(defun foo ()

(dotimes (i 10)

(format t "~d. hello~%" i)

)

)

правильный вариант:

(defun foo ()

(dotimes (i 10)

(format t "~d. hello~%" i)))

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

И, наконец, комментарии должны предваряться от одной до четырех точек с запятой, в зависимости от контекста появления этого комментария:

;;;; Четыре точки с запятой для комментария в начале файла

;;; Комментарий из трех точек с запятой обычно является параграфом комментариев,

;;; который предваряет большую секцию кода

(defun foo (x)

(dotimes (i x)

;; Две точки с запятой показывают, что комментарий применен к последующему коду.

;; Заметьте, что этот комментарий имеет такой же отступ, как и последующий код.

(some-function-call)

(another i) ; этот комментарий применим только к этой строке

(and-another) ; а этот для этой строки

(baz)))

Теперь вы готовы начать более детально рассматривать важнейшие строительный блоки программ Lisp: функции, переменные и макросы. Следующим шагом станут функции.

5
<p>5. Функции</p>

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

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

В конце концов, несмотря на важность макросов ( The Lisp Way! ), вся реальная функциональность обеспечивается функциями. Макросы выполняются во время компиляции и создают код программы. После того, как все макросы будут раскрыты, этот код полностью будет состоять из обращения к функциям и специальным операторам. Я не упоминаю, что макросы сами являются функциями, которые используются для генерации кода, а не для выполнения действий в программе. [61]

<p>Определение новых функций</p>

Обычно функции определяются при помощи макроса DEFUN . Типовое использование DEFUN выглядит вот так:

(defun name (parameter*)

"Optional documentation string."

тело-функции*)

В качестве имени может использоваться любой символ. [62] Как правило, имена функций содержат только буквы, цифры и знак минус, но, кроме того, разрешено использование других символов, и они используются в определенных случаях. Например, функции, которые преобразуют значения из одного типа в другой, иногда используют символ -> в имени. Или функция, которая преобразует строку в виджет, может быть названа string->widget . Наиболее важное соглашение по именованию, затронутое в главе 2, заключается в том, что лучше создавать составные имена, используя знак минус вместо подчеркивания или использования заглавных букв внутри имени. Так что frob-widget лучше соответствует стилю Lisp, чем frob_widget или frobWidget .

Список параметров функции определяет переменные, которые будут использоваться для хранения аргументов, переданных при вызове функции. [63] Если функция не принимает аргументов, то список пуст и записывается как () . Различют обязательные, необязательные, множественные, и именованные (keyword) параметры. Эти вопросы будут обсуждаться подробнее в следующем разделе.

За списком параметров может находиться строка, которая описывает назначение функции. После того, как функция определена, эта строка ( строка документации ) будет ассоциирована с именем функции и может быть позже получена с помощью функции DOCUMENTATION . [64]

Тело DEFUN состоит из любого числа выражений Lisp. При вызове функции они вычисляются по порядку, и результат вычисления последнего выражения возвращается, как значение функции. Для возврата из любой точки функции может использоваться специальный оператор RETURN-FROM , что я продемонстрирую через некоторое время.

В главе 2 мы написали функцию hello-world , которая выглядела вот так:

(defun hello-world () (format t "hello, world"))

Теперь вы можете проанализировать части этой функции. Она называется hello-world , список параметров пуст, потому что она не принимает аргументов, в ней нет строки документации, и ее тело состоит из одного выражения:

(format t "hello, world")

Вот пример немного более сложной функции:

(defun verbose-sum (x y)

"Sum any two numbers after printing a message."

(format t "Summing ~d and ~d.~%" x y)

(+ x y))

Эта функция называется verbose-sum , получает два аргумента, которые связываются с параметрами x и y , имеет строку документации, и ее тело состоит из двух выражений. Значение, возвращенное вызовом функции + , становится значением функции verbose-sum .

<p>Списки параметров функций</p>

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

Основное назначение списков параметров объявление переменных, которые будут использоваться для хранения аргументов, переданных функции. Когда список параметров является простым списком имен переменных, как в verbose-sum , то параметры называются обязательными . Когда функция вызывается, она должна получить ровно по одному аргументу для каждого из обязательных параметров. Каждый параметр связывается с соответствующим аргументом. Если функция вызывается с меньшим или большим количеством аргументов, чем требуется, то Lisp сообщит об ошибке.

Однако, списки параметров в Common Lisp предоставляют более удобные способы отображения аргументов функции в параметры функции. В дополнение к обязательным параметрам функция может иметь необязательные параметры. Или функция может иметь один параметр, который будет связан со списком, содержащим все дополнительные аргументы. И в заключение, аргументы могут быть связаны с параметрами путем использования ключевых слов (keywords), а не путем соответствия позиции параметра и аргумента в списке. Таким образом, списки параметров Common Lisp предоставляют удобное решение для некоторых общих задач кодирования.

<p>Необязательные параметры</p>

В то время как многие функции, подобно verbose-sum , нуждаются только в обязательных параметрах, не все функции являются настолько простыми. Иногда функции должны иметь параметр, который будет использоваться только при некоторых вызовах, поскольку он имеет "правильное" значение по умолчанию. Таким примером может быть функция, которая создает структуру данных, которая будет при необходимости расти. Поскольку, структура данных может расти, то не имеет значения, по большей части, какой начальный размер она имеет. Но пользователь функции, который имеет понятие о том, сколько данных будет помещено в данную структуру, может улучшить производительность программы путем указания нужного начального размера этой структуры. Однако, большинство пользователей данной функции, скорее всего, позволят выбрать наиболее подходящий размер автоматически. В Common Lisp вы можете предоставить этим пользователям одинаковые возможности с помощью необязательных параметров; пользователи, которые не хотят устанавливать значение сами, получат разумное значение по умолчанию, а остальные пользователи смогут подставить нужное значение. [65]

Для определения функции с необязательными параметрами после списка обязательных параметров поместите символ &optional , за которым перечислите имена необязательных параметров. Простой пример использования выглядит так:

(defun foo (a b &optional c d)

(list a b c d))

Когда функция будет вызвана, сначала аргументы связываются с обязательными параметрами. После того, как обязательные параметры получили переданные значения, и остались еще аргументы, то они будут присвоены необязательным параметрам. Если аргументы закончатся до того, как кончится список необязательных параметров, то оставшиеся параметры получат значение NIL . Таким образом, предыдущая функция будет выдавать следующие результаты:

(foo 1 2) ==> (1 2 NIL NIL)

(foo 1 2 3) ==> (1 2 3 NIL)

(foo 1 2 3 4) ==> (1 2 3 4)

Lisp все равно будет проверять количество аргументов, переданных функции (в нашем случае это число от 2 до 4-х, включительно), и будет выдавать ошибку, если функция вызвана с лишними аргументами, или их, наоборот, не достает.

Конечно, вы можете захотеть использовать другие значения по умолчанию, отличные от NIL . Вы можете указать их, путем замены имени параметра на список, состоящий из имени и выражения. Это выражение будет вычислено только если пользователь не указал значения для необязательного параметра. Общепринятым является простое задание конкретного значения в качестве выражения.

(defun foo (a &optional (b 10))

(list a b))

Эта функция требует указания одного аргумента, который будет присвоен параметру a . Второй параметр b , получит либо значение второго аргумента, если он указан, либо число 10.

(foo 1 2) ==> (1 2)

(foo 1) ==> (1 10)

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

(defun make-rectangle (width &optional (height width))

...)

что сделает параметр height равным параметру width , если только он не будет явно задан.

Иногда полезно будет знать, было ли значение необязательного параметра задано пользователем, или использовалось значение по умолчанию. Вместо того, чтобы писать код, который проверяет, является ли переданное значение равным значению по умолчанию (это все равно не будет работать, поскольку пользователь может явно задать значение, равное значению по умолчанию), вы можете добавить еще одно имя переменной к списку параметров после выражения для значения по умолчанию. Указанная переменная будет иметь истинное значение, если пользователь задал значение для аргумента, и NIL в противном случае. По соглашению, эти переменные называются также как и параметры, но с добавлением " -supplied-p " к концу имени. Например:

(defun foo (a b &optional (c 3 c-supplied-p))

(list a b c c-supplied-p))

Выполнение этого кода приведет к следующим результатам:

(foo 1 2) ==> (1 2 3 NIL)

(foo 1 2 3) ==> (1 2 3 T)

(foo 1 2 4) ==> (1 2 4 T)

<p>Остаточные (Rest) параметры</p>

Необязательные параметры применяются только тогда, когда у вас есть отдельные параметры, для которых пользователь может указывать или не указывать значения. Но некоторые функции могут требовать изменяемого количества аргументов. Некоторые встроенные функции, которые вы уже видели, работают именно так. Функция FORMAT имеет два обязательных аргумента поток вывода и управляющую строку. Но кроме этого, он требует переменное количество аргументов, зависящее от того, сколько значений он должен вставить в управляющую строку. Функция + также получает переменное количество аргументов нет никаких причин ограничиваться складыванием только двух чисел, эта функция может вычислять сумму любого количества значений. (Она даже может работать вообще без аргументов, возвращая значение 0 .) Следующие примеры являются допустимыми вызовами этих двух функций:

(format t "hello, world")

(format t "hello, ~a" name)

(format t "x: ~d y: ~d" x y)

(+)

(+ 1)

(+ 1 2)

(+ 1 2 3)

Очевидно, что вы можете написать функцию с переменным числом аргументов, просто описывая множество необязательных параметров. Но это будет невероятно мучительно простое написание списка параметров может быть не очень хорошим делом, и это не связывает все параметры с их использованием в теле функции. Для того, чтобы сделать это правильно, вы должны иметь число необязательных параметров равным максимальному допустимому количеству аргументов при вызове функций. Это число зависит от реализации, но гарантируется, что оно будет равно минимум 50 . В текущих реализациях оно варьируется от 4,096 до 536,870,911 . [66] Хех! Этот мозгодробительный подход явно не является хорошим стилем написания программ.

Вместо этого, Lisp позволяет вам указать параметр, который примет все аргументы (этот параметр указывается после символа &rest ). Если функция имеет параметр &rest (остаточный параметр), то любые аргументы, оставшиеся после связывания обязательных и необязательных параметров, будут собраны в список, который станет значением остаточного параметра &rest . Таким образом, список параметров для функций FORMAT и + будут выглядеть примерно так:

(defun format (stream string &rest values) ...)

(defun + (&rest numbers) ...)

<p>Именованые параметры =</p>

Необязательные и остаточные (rest) параметры дают вам достаточно гибкости, но ни один из них не помогает вам в следующей ситуации: предположим, что вы имеете функцию, которая получает четыре необязательных параметра. Теперь предположим, что пользователь захочет задать значение только для одного из параметров, и даже, что пользователь захочет задать значение только для некоторых, расположеных не последовательно, параметров.

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

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

Для того, чтобы задать именованные параметры, необходимо после всех требуемых, необязательных и остаточных параметров, указать символ &key и затем перечислить любое количество спецификаторов именованных параметров. Вот пример функции, которая имеет только именованные параметры:

(defun foo (&key a b c)

(list a b c))

Когда функция вызывается, каждый именованный параметр связывается со значением, которое указано после ключевого слова, имеющего то же имя, что и параметр. Вернемся к главе 4, в которой указывалось, что ключевые слова это имена, которые начинаются с двоеточия, и которые автоматически определяются как константы, вычисляемые сами в себя FIXME (self-evaluating).

Если ключевое слово не указано в списке аргументов, то соответствующий параметр получает значение по умолчанию, точно также как и для необязательный параметр. Поскольку именованные аргументы имеют метку, то они могут быть указаны в любом порядке, если они следуют после обязательных аргументов. Например, foo может быть вызвана вот так:

(foo) ==> (NIL NIL NIL)

(foo :a 1) ==> (1 NIL NIL)

(foo :b 1) ==> (NIL 1 NIL)

(foo :c 1) ==> (NIL NIL 1)

(foo :a 1 :c 3) ==> (1 NIL 3)

(foo :a 1 :b 2 :c 3) ==> (1 2 3)

(foo :a 1 :c 3 :b 2) ==> (1 2 3)

Также как и для необязательных параметров, именованные параметры могут задавать выражение для вычисления значения по умолчанию и имя supplied-p -переменной. И для необязательных, и для именованных параметров, значение по умолчанию может ссылаться на параметры, указанные ранее в списке.

(defun foo (&key (a 0) (b 0 b-supplied-p) (c (+ a b)))

(list a b c b-supplied-p))

(foo :a 1) ==> (1 0 1 NIL)

(foo :b 1) ==> (0 1 1 T)

(foo :b 1 :c 4) ==> (0 1 4 T)

(foo :a 2 :b 1 :c 4) ==> (2 1 4 T)

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

(defun foo (&key ((:apple a)) ((:box b) 0) ((:charlie c) 0 c-supplied-p))

(list a b c c-supplied-p))

позволяет пользователю вызывать функцию вот так:

(foo :apple 10 :box 20 :charlie 30) ==> (10 20 30 T)

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

<p>Совместное использование разных типов параметров</p>

Это возможно, но редко используется, использовать все четыре вида параметров в одной функции. Когда используется более одного типа параметров, они должны быть объявлены в порядке, который мы уже обсуждали сначала указываются имена требуемых параметров, затем - необязательных, затем - остаточных ( &rest ), и в заключение - именованных параметров. Но обычно в функциях, которые используют несколько типов параметров, комбинируют требуемые параметры с одним из других видов параметров, или возможно комбинируют необязательные и остаточные параметры. Два других сочетания необязательных или остаточных параметров с именованными параметрами, могут привести к очень удивительному поведению функции.

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

(defun foo (x &optional y &key z)

(list x y z))

Если она вызывается вот так, то все нормально:

(foo 1 2 :z 3) ==> (1 2 3)

И вот так, все работает нормально:

(foo 1) ==> (1 nil nil)

Но в этом случае, она выдает ошибку:

(foo 1 :z 3) ==> ERROR

Это происходит потому, что имя параметра :z берется как значение для необязательного параметра y , оставляя для обработки только аргумент 3 . При этом, Lisp ожидает, что в этом месте встретится либо пара имя/значение, либо не будет ничего, и одиночное значение приведет к выдаче ошибки. Будет даже хуже, если функция будет иметь два необязательных параметра, так что использование функции как в последнем примере, приведет к тому, что значения :z и 3 будут присвоены двум необязательным параметрам, а именованный параметр z получит значение по умолчанию NIL , без всякого указания, что что-то произошло неправильно.

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

Вы можете безопасно комбинировать остаточные и именованные параметры, но вначале поведение может показаться немного удивительным. Обычно, наличие либо остаточных, либо именованных параметров приведет к тому, что значения, оставшиеся после заполнения всех обязательных и необязательных параметров, будут обработаны определенным образом либо собраны в список (для остаточных параметров), или присвоены соответствующим именованным параметрам. Если в списке параметров используются и остаточные и именованные параметры, то выполняются оба действия все оставшиеся значения собираются в список, который присваивается параметру &rest , а также соответствующие значения присваиваются именованным параметрам. Так что имея следующую функцию:

(defun foo (&rest rest &key a b c)

(list rest a b c))

вы получите следующие результаты:

(foo :a 1 :b 2 :c 3) ==> ((:A 1 :B 2 :C 3) 1 2 3)

<p>Возврат значений из функции</p>

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

Однако, иногда бывает нужно вернуть значение из середины функции, вырываясь таким образом из вложенных управляющих конструкций. В таком случае вы можете использовать специальный оператор RETURN-FROM , который предназначен для немедленного возвращения любого значения из функции.

Вы увидите в главе 20 что RETURN-FROM в самом деле не привязана к функциям; она используется для возврата из блока кода, определенного с помощью оператора BLOCK . Однако, DEFUN автоматически помещает тело функции в блок кода с тем же именем, что и имя функции. Так что, вычисление RETURN-FROM с именем функции и значением, которое вы хотите возвратить, приведет к немедленному выходу из функции с возвратом указанного значения. RETURN-FROM является специальным оператором, чьим первым аргументом является имя блока из которого необходимо выполнить возврат. Это имя не вычисляется, так что нет нужды его экранировать.

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

(defun foo (n)

(dotimes (i 10)

(dotimes (j 10)

(when (> (* i j) n)

(return-from foo (list i j))))))

Надо отметить, что необходимость указания имени функции из которой вы хотите вернуться, является не особо удобной если вы измените имя функции, то вам нужно будет также изменить имя, использованное в операторе RETURN-FROM . [68] Но следует отметить, что явное использование RETURN-FROM в Lisp происходит значительно реже, чем использование выражения return в C-подобных языках, поскольку все выражения Lisp, включая управляющие конструкции, такие как условные выражения и циклы, вычисляются в значения. Так что это не представляет особой сложности на практике.

<p>Функции как данные, или Функции высшего порядка</p>

В то время как основной способ использования функций это вызов их с указанием имени, существуют ситуации, когда было бы полезно рассматривать функции как данные. Например, вы можете передать одну функцию в качестве аргумента другой функции, вы можете написать общую функцию сортировки, и предоставить пользователю возможность указания функции для сравнения двух элементов. Так что один и тот же алгоритм может быть использоваться с разными функциями сравнения. Аналогично, обратные вызовы (callbacks) и FIXME hooks зависят от возможности хранения ссылок на исполняемый код, который можно выполнить позже. Поскольку функции уже являются стандартным способом представления частей кода, имеет смысл разрешить рассмотрение функций как данных. [69]

В Lisp функции являются просто другим типом объектов. Когда вы определяете функцию с помощью DEFUN , вы в действительности делаете две вещи: создаете новый объект-функцию, и даете ему имя. Кроме того, возможно, как вы увидели в главе 3, использовать LAMBDA для создания функции без имени. Действительное представление объекта-функции, независимо от того, именованный он или нет, является неопределенным в компилируемых вариантах Lisp, они вероятно состоят в основном из машинного кода. Единственными вещами которые вам надо знать как получить эти объекты, и как выполнять их, если вы их получили.

Специальный оператор FUNCTION обеспечивает механизм получения объекта-функции. Он принимает единственный аргумент и возвращает функцию с этим именем. Имя не экранируется. Так что, если вы определили функцию foo , например вот так:

CL-USER> (defun foo (x) (* 2 x))

FOO

вы можете получить объект-функцию следующим образом: [70]

CL-USER> (function foo)

#<Interpreted Function FOO>

В действительности, вы уже использовали FUNCTION , но это было замаскировано. Синтаксис # ', который вы использовали в главе 3, является синтаксической оберткой для FUNCTION , точно также как и ' является оберткой для QUOTE . [71] Так что вы можете получить объект-функцию вот так:

CL-USER> #'foo

#<Interpreted Function FOO>

После того, как вы получили объект-функцию, есть только одна вещь, которую вы можете сделать с ней выполнить ее. Common Lisp предоставляет две функции для выполнения функции через объект-функцию: FUNCALL и APPLY . [72] Они отличаются тем, как они получают аргументы, которые будут переданы вызываемой функции.

FUNCALL это функция, которая используется тогда, когда во время написания кода вы знаете количество аргументов, которые вы будете передавать функции. Первым аргументом FUNCALL является запускаемый объект-функция, а оставшиеся аргументы передаются данной функции. Так что следующие два выражения являются эквивалентными:

(foo 1 2 3) === (funcall #'foo 1 2 3)

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

Следующая функция демонстрирует более реалистичное использование FUNCALL . Она принимает объект-функцию в качестве аргумента, и рисует простую текстовую диаграмму, значений, возвращенных функцией, вызываемой для значений от min до max с шагом step .

(defun plot (fn min max step)

(loop for i from min to max by step do

(loop repeat (funcall fn i) do (format t "*"))

(format t "~%")))

Выражение FUNCALL вычисляет значение функции для каждого значения i . Внутрениий цикл использует это значение для определения того, сколько раз напечатать знак "звездочка".

Заметьте, что вы не используете FUNCTION или # ' для получения значения fn ; вы хотите, чтобы оно интерпретировалось как переменная, поскольку значение этой переменной является объектом-функцией. Вы можете вызвать plot с любой функцией, которая берет один числовой аргумент, например, со встроенной функцией EXP , которая возвращает значение e , возведенное в степень переданного аргумента.

CL-USER> (plot #'exp 0 4 1/2)

*

*

**

****

*******

************

********************

*********************************

******************************************************

NIL

Однако FUNCALL не особо полезен, когда список аргументов становится известен только во время выполнения. Например, для работы с функцией plot в других случаях, представьте, что вы получили список, содержащий объект-функцию, минимальное и максимальное значения, а также шаг изменения значений. Другими словами, список содержит значения, которые вы хотите передать как аргументы для plot . Предположим, что этот список находится в переменной plot-data . Вы можете вызвать plot с этими значениями вот так вот:

(plot

(first plot-data)

(second plot-data)

(third plot-data)

(fourth plot-data))

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

Это как раз тот случай, когда на помощь приходит APPLY . Подобно FUNCALL , ее первым аргументом является объект-функция. Но после первого аргумента, вместо перечисления отдельных аргументов, она принимает список. Затем APPLY применяет функцию к значениям в списке. Это позволяет вам переписать предыдущий код следующим образом:

(apply #'plot plot-data)

Кроме того, APPLY может также принимать "свободные" аргументы, также как и обычные аргументы в списке. Таким образом, если plot-data содержит только значения для min , max и step , то вы все равно можете использовать APPLY для отображения функции EXP используя следующее выражение:

(apply #'plot #'exp plot-data)

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

<p>Анонимные функции</p>

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

Когда кажется, что определение новых функций с помощью DEFUN является излишним, вы можете создать "анонимную" функцию, используя выражение LAMBDA . Как обсуждалось в главе 3, LAMBDA -выражение выглядит примерно так:

(lambda (parameters) body)

Можно представить себе, что LAMBDA -выражения это специальный вид имен функций, где само имя напрямую описывает что эта функция делает. Это объясняет, почему вы можете использовать LAMBDA -выражение вместо имени функции с # '.

(funcall #'(lambda (x y) (+ x y)) 2 3) ==> 5

Вы даже можете использовать LAMBDA -выражение как "имя" функции в выражениях, вызывающих функцию. Если вы хотите, то вы можете переписать предыдущий пример с FUNCALL в следующем виде.

((lambda (x y) (+ x y)) 2 3) ==> 5

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

Анонимные функции могут быть очень полезными, когда вы хотите передать одну функцию в качестве аргумента другой, и она достаточно проста для записи на месте. Например, предположим, что вы хотите нарисовать график функции 2x . Вы можете определить следующую функцию:

(defun double (x) (* 2 x))

которую затем передать plot .

CL-USER> (plot #'double 0 10 1)

**

****

******

********

**********

************

**************

****************

******************

********************

NIL

Но легче и более понятно написать вот так:

CL-USER> (plot #'(lambda (x) (* 2 x)) 0 10 1)

**

****

******

********

**********

************

**************

****************

******************

********************

NIL

Другим очень важным применением LAMBDA -выражений является их использование для создания замыканий (closures) функций, которые захватывают часть среды выполнения, в которой они были созданы. Вы уже использовали замыкания в главе 3, но подробное описание о том, как замыкания работают и как они могут использоваться, больше относится к переменным, а не к функциям, так что я отложу это обсуждение до следующей главы.

6
<p>6. Переменные</p>

Следующим базовым строительным блоком, с которым нам нужно ознакомиться, переменные. Common Lisp поддерживает два вида переменных: лексические и динамические [74]. Эти два типа переменных примерно соответствуют "локальным" и "глобальным" переменным других языков. Однако, это соответствие лишь приблизительно. С одной стороны "локальные" переменные некоторых языков в действительности гораздо ближе к динамическим переменным Common Lisp [75]. И, с другой, локальные переменные некоторых других языков имеют лексическую область видимости не предоставляя всех возможностей, предоставляемых лексическими переменными Common Lisp. В частности, не все языки, предоставляющие переменные, имеющие лексическую область видимости, поддерживают замыкания.

Чтобы сделать все еще более запутанным, многие формы, которые работают с переменными, могут использоваться как с лексическими, так и с динамическими переменными. Поэтому я начну с обсуждения некоторых аспектов переменных Lisp, которые применимы к обоим видам переменных, а затем рассмотрю специфические характеристики лексических и динамических переменных. Далее я обсужу оператор присваивания общего назначения Common Lisp, SETF , который используется для присваивания новых значений переменным и просто почти каждому месту, которое может содержать значение.

<p>Основы переменных</p>

Как и в других языках, в Common Lisp переменные являются именованными местами, которые могут содержать значения. Однако, в Common Lisp переменные не типизированы таким же образом, как в таких языках, как Java или C++. То есть вам не нужно описывать тип объектов, которые может содержать каждая переменная. Вместо этого переменная может содержать значения любого типа и сами значения содержат информацию о типе, которая может быть использована для проверки типов во время выполнения. Таким образом, Common Lisp является динамически типизированным : ошибки типов выявляются динамически. Например, если вы передадите не число в функцию +, Common Lisp сообщит вам об ошибке типов. С другой стороны, Common Lisp является строго типизированным языком в том смысле, что все ошибки типов будут обнаружены: нет способа представить объект в качестве экземпляра класса, которым он не является [76].

Все значения в Common Lisp, по крайней мере концептуально, являются ссылками на объекты [77]. Поэтому присваивание переменной нового значения изменяет то, на какой объект ссылается переменная (то есть, куда ссылается переменная), но не оказывает никакого влияния на объект, на который переменная ссылалась ранее. Однако, если переменная содержит ссылку на изменяемый объект, вы можете использовать данную ссылку для изменения этого объекта, и это изменение будет видимо любому коду, который имеет ссылку на этот же объект.

Один из способов введения новой переменной вы уже использовали при определении параметров функции. Как вы видели в предыдущей главе, при определении функции с помощью DEFUN список параметров определяет переменные, которые будут содержать аргументы, переданные функции при вызове. Например, следующая функция определяет три переменные для хранения своих аргументов: x, y и z.

(defun foo (x y z) (+ x y z))

При каждом вызове функции Lisp создает новые привязки ( bindings ) для хранения аргументов, переданных при вызове этой функции. Привязка является проявлением переменной во время выполнения. Отдельная переменная сущность, на которую вы можете сослаться в исходном коде своей программы может иметь множество различных привязок за время выполнения программы. Отдельная переменная даже может иметь множество привязок в одно и то же время: параметры рекурсивной функции, например, связываются заново (rebound) при каждом вызове это функции.

Другой формой, позволяющей вводить новые переменные, является специальный оператор LET . Шаблон формы LET имеет следующий вид:

(let (variable*)

body-form*)

где каждая variable является формой инициализации переменной. Каждая форма инициализации является либо списком, содержащим имя переменной и форму начального значения, либо, как сокращение для инициализации переменной в значение NIL , просто именем переменной. Следующая форма LET , например, связывает три переменные x, y и z с начальными значениями 10, 20 и NIL :

(let ((x 10) (y 20) z)

...)

При вычислении формы LET сначала вычисляются все формы начальных значений. Затем, перед выполнением форм тела, создаются и инициализируются в соответствующие начальные значения новые привязки. Внутри тела LET имена переменных ссылаются на только что вновь созданные привязки. После LET имена продолжают ссылаются на то, на что они ссылались перед LET (если они на что-то ссылались).

Значение последнего выражения тела возвращается как значение выражения LET . Как и параметры функций, переменные, вводимые LET , связываются заново (rebound) каждый раз, когда поток управления заходит в LET [78].

Область видимости ( scope ) параметров функций и переменных LET область программы, где имя переменной может быть использовано для ссылки на привязку переменной ограничивается формой, которая вводит переменную. Такая форма (определение функции или LET ) называется связывающей формой ( binding form ). Как вы скоро увидите, два типа переменных (лексические и динамические) используют два несколько отличающихся механизма области видимости, но в обоих случаях область видимости ограничена связывающей формой.

Если вы записываете вложенные связывающие формы, которые вводят переменные с одинаковыми именами, то привязки внутренних переменных скрывают внешние привязки. Например, при вызове следующей функции для параметра x создается привязка для хранения аргумента функции. Затем первая LET создает новую привязку с начальным значением 2, а внутренняя LET создает еще одну привязку с начальным значением 3. Комментарии справа указывают область видимости каждой привязки.

(defun foo (x)

(format t "Параметр: ~a~%" x) ; |<------ x - аргумент

(let ((x 2)) ; |

(format t "Внешний LET: ~a~%" x) ; | |<---- x = 2

(let ((x 3)) ; | |

(format t "Внутренний LET: ~a~%" x)) ; | | |<-- x = 3

(format t "Внешний LET: ~a~%" x)) ; | |

(format t "Параметр: ~a~%" x)) ; |

Каждое обращение к x будет ссылаться на привязку с наименьшей окружающей областью видимости. Как только поток управления покидает область видимости какой-то связывающей формы, привязка из непосредственно окрущающей области видимости перестает скрываться и x ссылается уже на нее. Таким образом, результатом вызова foo будет следующий вывод:

CL-USER> (foo 1)

Параметр: 1

Внешний LET: 2

Внутренний LET: 3

Внешний LET: 2

Параметр: 1

NIL

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

Например, в главе 7 вы встретите цикл DOTIMES , простой цикл-счетчик. Он вводит переменную, которая содержит значение счетчика, увеличивающегося на каждой итерации цикла. Например, следующий цикл, печатающий числа от 0 до 9, связывает переменную x:

(dotimes (x 10) (format t "~d " x))

Еще одной связывающей формой является вариант LET , LET *. Различие состоит в том, что в LET имена переменных могут быть использованы только в теле LET (части LET , идущей после списка переменных), а в LET * формы начальных значений для каждой переменной могут ссылаться на переменные, введенные ранее в списке переменных. Таким образом, вы можете записать следующее:

(let* ((x 10)

(y (+ x 10)))

(list x y))

но не так:

(let ((x 10)

(y (+ x 10)))

(list x y))

Однако, вы можете добиться такого же результата при помощи вложенных LET .

(let ((x 10))

(let ((y (+ x 10)))

(list x y)))

<p>Лексические переменные и замыкания</p>

По умолчанию все связывающие формы в Common Lisp вводят переменные лексической области видимости ( lexically scoped ). На переменные лексической области видимости можно ссылаться только в коде, который текстуально находится внутри связывающей формы. Лексическая область видимости должна быть знакома каждому, кто программировал на Java, C, Perl или Python, так как все они предоставляют "локальные" переменные, имеющие лексическую область видимости. Программисты на Algol также должны чувствовать себя хорошо, так как этот язык первым ввел лексическую область видимости в 1960-х.

Однако, лексические переменные Common Lisp несколько искажают понятие лексической переменной, по крайней мере в сравнении с оригинальной моделью Algol. Это искажение проявляется при комбинировании лексической области видимости со вложенными функциями. По правилам лексической области видимости, только код, текстуально находящийся внутри связывающей формы, может ссылаться на лексическую переменную. Но что произойдет, когда анонимная функция содержит ссылку на лексическую переменную из окружающей области видимости? Например, в следующем выражении:

(let ((count 0)) #'(lambda () (setf count (+ 1 count))))

ссылка на count внутри формы LAMBDA допустима в соответствии с правилами лексической области видимости. Однако, анонимная функция, содержащая ссылку, будет возвращена как значение формы LET , и она может быть вызвана с помощью FUNCALL кодом, который не находится в области видимости LET . Так что же произойдет? Как выясняется, если count является лексической переменной, все работает. Привязка count, созданная когда поток управления зашел в форму LET , остается столько, сколько это необходимо, в данном случае до тех пор, пока что-то сохраняет ссылку на функциональный объект, возвращенный формой LET . Анонимная функция называется замыканием ( closure ), потому что она "замыкается вокруг" привязки, созданной LET .

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

(defparameter *fn* (let ((count 0)) #'(lambda () (setf count (1+ count)))))

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

CL-USER> (funcall *fn*)

1

CL-USER> (funcall *fn*)

2

CL-USER> (funcall *fn*)

3

Отдельное замыкание может "замыкаться вокруг" нескольких привязок переменных просто ссылаясь на них. Также множество замыканий могут захватывать одну и ту же привязку. Например, следующее выражение возвращает список трех замыканий, первое из которых увеличивает значение привязки count, вокруг которой оно "замкнуто", второе уменьшает его, а третье возвращает текущее значение:

(let ((count 0))

(list

#'(lambda () (incf count))

#'(lambda () (decf count))

#'(lambda () count)))

<p>Динамические (специальные) переменные</p>

Привязки с лексической областью видимости помогают поддерживать код понятным путем ограничения области видимости, в которой, буквально говоря, данное имя имеет смысл. Вот почему большинство современных языков программирования используют лексическую область видимости для локальных перменных. Однако, иногда вам действительно может понадобиться глобальная переменная переменная, к который вы можете обратиться из любой части своей программы. Хотя неразборчивое использование глобальных переменных может привести к "спагетти-коду" также быстро как и неумеренное использование goto, глобальные переменные имеют разумное использование и существуют в том или ином виде почти в каждом языке программирования [79]. И, как вы сейчас увидите, глобальные переменные Lisp, динамические переменные, одновременно и более удобны, и более гибки.

Common Lisp предоставляет два способа создания глобальных переменных: DEFVAR и DEFPARAMETER . Обе формы принимают имя переменной, начальное значение и опциональную строку документации. После создания переменной с помощью DEFVAR или DEFPARAMETER имя может быть использовано где угодно для ссылки на текущую привязку этой глобальной переменной. Как вы заметили в предыдущих главах, по соглашению глобальные переменные именуются именами, начинающимися и заканчивающимися *. Далее в этой главе вы увидите почему очень важно следовать этому соглашению по именованию. Примеры DEFVAR и DEFPARAMETER выглядят следующим образом:

(defvar *count* 0

"Число уже созданных виджетов.")

(defparameter *gap-tolerance* 0.001

"Допустимое отклонение интервала между виджетами.")

Различие между этими двумя формами состоит в том, что DEFPARAMETER всегда присваивает начальное значение названной переменной, а DEFVAR делает это только если переменная не определена. Форма DEFVAR также может использоваться без начального значения для определения глобальной переменной без установки ее значения. Такая переменная называется несвязанной ( unbound ).

На деле вам следует использовать DEFVAR для определения переменных, которые будут содержать данные, которые вы хотите сохранять даже при изменениях исходного кода, использующего эту переменную. Например, представьте, что две переменные, определенные ранее, являются частью приложения управления "фабрикой виджетов" [80]. Правильным будет определить переменную *count* с помощью DEFVAR , так как число уже созданных виджетов не становится недействительным лишь потому, что мы сделали некоторые изменения в коде создания виджетов [81].

С другой стороны, переменная *gap-tolerance* вероятно влияет некоторым образом на поведение самого кода создания виджетов. Если вы решите, что вам нужно меньшее или большее допустимое отклонение и, следовательно, измените значение в форме DEFPARAMETER, вы захотите, чтобы изменение вступило в силу при перекомпиляции и перезагрузке файла.

После определения переменной с помощью DEFVAR или DEFPARAMETER вы можете ссылаться на нее откуда угодно. Например, вы можете определить следующую функцию для увеличения числа созданных виджетов:

(defun increment-widget-count () (incf *count*))

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

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

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

Это работает замечательно, пока вы не забудете восстановить исходное значение *standard-output* после завершения действий. Если вы забудете восстановить *standard-output*, весь остальной код программы, использующий *standard-output*, также будет слать свой вывод в файл [82].

Но похоже, что то, что вам действительно нужно, это способ обернуть часть кода во что-то говорящее: "Весь нижележащий код (все функции, которые он вызывает, все функции, которые вызывают эти функции, и так далее до функций самого низкого уровня) должны использовать это значение для глобальной переменной *standard-output*". А затем, по завершении работы функции верхнего уровня, старое значение *standard-output* должно быть автоматически восстановлено.

Оказывается, что это именно те возможности, что предоставляет вам другой вид переменных Common Lisp: динамические переменные. Когда вы связываете динамическую переменную, например с LET -переменной или с параметром функции, привязка, создаваемая во время входа в связывающую форму, заменяет глобальную привязку на все время выполнения связывающей формы. В отличие от лексических привязок, к которым можно обращаться только из кода, находящегося в лексической области видимости связывающей формы, к динамическим привязкам можно обращаться из любого кода, вызываемого во время выполнения связывающей формы [83]. И оказывается, что все глобальные переменные на самом деле являются динамическими.

Таким образом, если вы хотите временно переопределить *standard-output*, это можно сделать просто пересвязав ее, например, с помощью LET .

(let ((*standard-output* *some-other-stream*))

(stuff))

В любом коде, который выполняется в результате вызова stuff, ссылки на *standard-output* будут использовать привязку, установленную с помощью LET . А после того как stuff завершится и поток управления покинет LET , новая привязка *standard-output* исчезнет и последующие обращения к *standard-output* будут видеть привязку, бывшую до LET . В любой момент времени самая последняя установленная привязка скрывает все остальные. Можно представить, что каждая новая привязка данной динамической переменной помещается в стек привязок этой переменной и ссылки на эту переменную всегда используют последнюю установленную привязку. После выхода из связывающей формы созданные в ней привязки убираются из стека, делая видимыми предыдующие привязки [84].

Простой пример показывает как это работает:

(defvar *x* 10)

(defun foo () (format t "X: ~d~%" *x*))

DEFVAR создает глобальную привязку переменной *x* со значением 10. Обращение к *x* в foo будет искать текущую привязку динамически. Если вы вызовете foo на верхнем уровне (from the top level), глобальная привязка, созданная DEFVAR , будет единственной доступной привязкой, поэтому будет напечатано 10.

CL-USER> (foo)

X: 10

NIL

Но вы можете использовать LET для создания новой привязки, которая временно скроет глобальную привязку, и foo напечатает другое значение.

CL-USER> (let ((*x* 20)) (foo))

X: 20

NIL

Теперь снова вызовем foo без LET , она опять будет видеть глобальную привязку.

CL-USER> (foo)

X: 10

NIL

Теперь определим новую функцию.

(defun bar ()

(foo)

(let ((*x* 20)) (foo))

(foo))

Обратите внимание, что средний вызов foo находится внутри LET , которая связывает *x* с новым значением 20. При вызове bar вы получите следующий результат:

CL-USER> (bar)

X: 10

X: 20

X: 10

NIL

Как вы можете заметить, первый вызов foo видит глобальную привязку со значением 10. Средний вызов видит новую привязку со значением 20. А после LET , foo снова видит глобальную привязку.

Как и с лексической привязкой, присваивание нового значения влияет только на текущую привязку. Чтобы увидеть это, вы можете переопределить foo, добавив присваивание значения переменной *x*.

(defun foo ()

(format t "Перед присваиванием~18tX: ~d~%" *x*)

(setf *x* (+ 1 *x*))

(format t "После присваивания~18tX: ~d~%" *x*))

Теперь foo печатает значение *x*, увеличивает его на единицу, а затем печатает его снова. Если вы просто запустите foo, вы увидите следующее:

CL-USER> (foo)

Перед присваиванием X: 10

После присваивания X: 11

NIL

Ничего удивительного. Теперь запустим bar.

CL-USER> (bar)

Перед присваиванием X: 11

После присваивания X: 12

Перед присваиванием X: 20

После присваивания X: 21

Перед присваиванием X: 12

После присваивания X: 13

NIL

Обратите внимание, начальное значение *x* равно 11: предыдущий вызов foo действительно изменил глобальное значение. Первый вызов foo из bar увеличивает глобальную привязку до 12. Средний вызов не видит глобальную привязку из-за LET . А затем последний вызов снова может видеть глобальную привязку и увеличивает ее с 12 до 13.

Так как это работает? Как LET знает, когда связывает *x*, что подразумевается создание динамической привязки вместо обычной лексической? Она знает, так как имя было объявлено специальным [85]. Имя каждой переменной, определенной с помощью DEFVAR и DEFPARAMETER автоматически глобально объявляется специальным. Это означает, что когда бы вы не использовали это имя в связывающей форме (в форме LET , или как параметр функции, или в любой другой конструкции, которая создает новую привязку переменной, вновь создаваемая привязка будет динамической. Вот почему *соглашение* *по* *именованию* так важно: будет не очень хорошо, если вы используете имя, о котором вы думаете как о лексической переменной, а эта переменная окажется глобальной специальной. С одной стороны, код, который вы вызываете, сможет изменить значение этой связи; с другой, вы сами можете скрыть связь, установленную кодом, находящимся выше по стеку. Если вы всегда будете именовать глобальные переменные, используя соглашение по именованию *, вы никогда случайно не воспользуетесь динамической связью, желая создать лексическую.

Также возможно локально объявить имя специальным. Если в связывающей форме вы объявите имя специальным, привязка, созданная для этой переменной, будет динамической, а не лексической. Другой код может локально определить имя специальным, чтобы обращаться к динамической привязке. Однако, локальные специальные переменные используются относительно редко, поэтому вам не стоит беспокоиться о них [86].

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

<p>Константы</p>

Еще одним видом переменных, вообще не упомянутых ранее, являются оксюморонические "константные переменные". Все константы являются глобальными и определяются с помощью DEFCONSTANT . Базовая форма DEFCONSTANT подобна DEFPARAMETER .

(defconstant name initial-value-form [ documentation-string ])

Как и в случае с DEFPARAMETER , DEFCONSTANT оказывает глобальный эффект на используемое имя: после этого имя может быть использовано только для обращения к константе; оно не может быть использовано как параметр функции или быть пересвязано с помощью любой другой связывающей формы. Поэтому многие программисты на Lisp следуют соглашению по именованию и используют для констант имена начинающиеся и заканчивающиеся +. Этому соглашению следуют немного в меньшей степени, чем соглашению для глобальных динамических имен, но оно является хорошей идеей по сходным причинам [87].

Еще на что нужно обратить внимание по поводу DEFCONSTANT это то, что в то время, как язык позволяет вам переопределять константы путем перевычисления DEFCONSTANT с другой формой начального значения, не определено то, что именно произойдет после такого переопределения. На практике большинство реализаций требуют, чтобы вы перевычислили любой код, ссылающийся на константу, чтобы изменение вступило в силу, так как старое значение могло быть встроено (inlined). Следовательно, правильным будет использовать DEFCONSTANT для определения только тех вещей, которые действительно являются константами, такие как значение NIL. Для вещей, которые вам может когда-нибудь понадобиться изменить, следует использовать DEFPARAMETER .

<p>Присваивание</p>

После создания привязки вы можете совершать с ней два действия: получить текущее значение и установить ей новое значение. Как вы видели в главе 4, символ вычисляется в значение переменной, которую он именует, поэтому вы можете получить текущее значение просто обратившись к переменной. Для присваивания нового значения привязке используйте макрос SETF , являющийся в Common Lisp оператором присваивания общего назначения. Базовая форма SETF следующая:

(setf place value)

Так как SETF является макросом, он может оценить форму "места", которому он осуществляет присваивание и расшириться (expand) в соответствующие низкоуровневые операции, осуществляющие необходимые действия. Когда "место" является переменной, этот макрос расширяется в вызов специального оператора SETQ , который, как специальный оператор, имеет доступ и к лексическим, и к динамическим привязкам [88]. Например, для присваивания значения 10 переменной x вы можете написать это:

(setf x 10)

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

(defun foo (x) (setf x 10))

не окажет никакого влияния на любое значение вне foo. Привязка, которая создается при вызове foo, устанавливается в 10, незамедлительно заменяя то значение, что было передано в качестве аргумента. В частности, следующая форма:

(let ((y 20))

(foo y)

(print y))

напечатает 20, а не 10, так как именно оно является значением y , которое передается foo , где уже является значением переменной x перед тем, как SETF дает x новое значение.

SETF также может осуществить последовательное присваивание множеству "мест". Например, вместо следующего:

(setf x 1)

(setf y 2)

вы можете записать следующее:

(setf x 1 y 2)

SETF возвращает присвоенное значение, поэтому вы можете вкладывать вызовы SETF как в следующем примере, который присваивает и x, и y одинаковое случайное значение:

(setf x (setf y (random 10)))

<p>Обобщенное присваивание</p>

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

Я опишу эти структуры данных в последующих главах, но так как мы рассматриваем присваивание, вы должны знать, что SETF может присвоить значение любому "месту". Когда я буду описывать различные составные структуры данных, я буду указывать, какие функции могут использоваться как "места, обрабатываемые SETF " (" SETFable places"). Кратко же можно сказать, что если вам нужно присвоить значение "месту", почти наверняка следует использовать SETF . Возможно даже расширить SETF для того, чтобы он мог осуществлять присваивание определенным пользователем "местам", хотя я не описываю такие возможности [89].

В этом отношении SETF не отличается от оператора присваивания = языков, произошедших от C. В этих языках оператор = присваивает новые значения переменным, элементам массивов, полям классов. В языках, таких как Perl и Python, которые поддерживают хэш-таблицы как встроенные типы данных, = может также устанавливать значения элементов хэш-таблицы. Таблица 6-1 резюмирует различные способы, которыми используется = в этих языках.

Таблица 6-1. Присваивание с помощью = в других языках программирования

Присваивание ... Java, C, C++ Perl Python ^ ... переменной x = 10; $x = 10; x = 10 | ... элементу массива a[0] = 10; $a[0] = 10; a[0] = 10 | ... элементу хэш-таблицы $hash{'key'} = 10; hash['key'] = 10 | ... полю объекта o.field = 10; $o->{'field'} = 10; o.field = 10 |

SETF работает сходным образом: первый "аргумент" SETF является "местом" для хранения значения, а второй предоставляет само значения. Как и с оператором = в этих языках, вы используете одинаковую форму и для выражения "места", и для получения значения [90]. Таким образом, эквиваленты вышеприведенных в таблице 6-1 присваиваний для Lisp следующие ( AREF функция доступа к массиву, GETHASH осуществляет операцию поиска в хэш-таблице, а field может быть функцией, которая обращается к слоту под именем field определенного пользователем объекта):

Простая переменная: (setf x 10)

Массив: (setf (aref a 0) 10)

Хэш-таблица: (setf (gethash 'key hash) 10)

Слот с именем 'field': (setf (field o) 10)

Обратите внимание, что присваиваение с помощью SETF "месту", которое является частью большего объекта, имеет ту же семантику, что и присваивание переменной: "место" модифицируется без оказания какого-либо влияния на объект, которых хранился там до этого. И вновь, это подобно тому, как ведет себя = в Java, Perl и Python [91].

<p>Другие способы изменения "мест"</p>

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

(setf x (+ x 1))

или уменьшить его так:

(setf x (- x 1))

Но это слегка утомительно по сравнению с стилем C: ++x и x. Вместо этого вы можете использовать макросы INCF и DECF , которые увеличивают и уменьшают "место" на определенную величину, по умолчанию 1.

(incf x) === (setf x (+ x 1))

(decf x) === (setf x (- x 1))

(incf x 10) === (setf x (+ x 10))

INCF и DECF являются примерами определенного вида макросов, называемых модифицирующими макросами ( modify macros ). Модифицирующие макросы являются макросами, построенными поверх SETF , которые модифицируют "места" путем присваивания нового значения, основанного на их текущем значении. Главным преимуществом таких макросов является то, что они более краткие, чем аналогичные операции, записанные с помощью SETF . Вдобавок, модифицирующие макросы определены таким образом, что делает их безопасными при использовании с "местами", когда выражение "места" должно быть вычислено лишь единожды. Несколько надуманным примером является следующее выражение, которое увеличивает значение произвольного элемента массива:

(incf (aref *array* (random (length *array*))))

Наивный перевод этого примера в выражение, использующее SETF , может выглядить следующим образом:

(setf (aref *array* (random (length *array*)))

(1+ (aref *array* (random (length *array*)))))

Однако, это не работает, так как два последовательных вызова RANDOM не обязательно вернут одинаковое значение: это выражение вероятно получит значение одного элемента массива, увеличит его, а затем сохранит его как новое значение другого элемента массива. Однако, выражение INCF сделает все правильно, так как знает, как правильно разобрать это выражение:

(aref *array* (random (length *array*)))

чтобы извлечь те части, которые возможно могут иметь побочные эффекты, и гарантировать, что они будут вычисляться лишь один раз. В этом случае, выражение INCF вероятно расширится в нечто более или менее подобное этому:

(let ((tmp (random (length *array*))))

(setf (aref *array* tmp) (1+ (aref *array* tmp))))

Вообще, модифицирующие макросы гарантируют однократное вычисление слева направо своих аргументов, а также подформ формы места (place form).

Макрос PUSH , который вы использовали в примере с базой данных для добавления элементов в переменную *db*, является еще одним модифицирующим макросом. Более подробно о его работе и работе POP и PUSHNEW будет сказано в главе 12, где я буду говорить о том, как представляются (represented) списки в Lisp.

И наконец, два слегка эзотерических, но полезных модифицирующих макроса ROTATEF и SHIFTF . ROTATEF циклически сдвигает значение между "местами". Например, если вы имеете две переменные, a и b, этот вызов:

(rotatef a b)

обменяет значения двух переменных и вернет NIL . Так как a и b являются переменными и вам не нужно беспокоиться о побочных эффектах, предыдущее выражение ROTATEF эквивалентно следующему:

(let ((tmp a)) (setf a b b tmp) nil)

С другими видами "мест" эквивалентное выражение с использованием SETF может быть более сложным.

SHIFTF подобен ROTATEF за исключением того, что вместо циклического сдвига значений, он просто сдвигает их влево: последний аргумент предоставляет значение, которое перемещается в предпоследний аргумент и так далее. Исходное значение первого аргумента просто возвращается. Таким образом, следующее:

(shiftf a b 10)

эквивалентно (и снова, так как вам не нужно беспокоиться о побочных эффектах) следующему:

(let ((tmp a)) (setf a b b 10) tmp)

И ROTATEF , и SHIFTF оба могут использоваться с любым числом аргументов и, как все модифицирующие макросы, гарантируют однократное их вычисление слева направо.

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

7
<p>7. Макросы: Стандартные управляющие конструкции</p>

В то время, как многие из идей, появившиеся в Лиспе, от выражений условия до сборки мусора, были добавлены в другие языки, есть одна особенность языка, которая продолжает делать Common Lisp стоящим особняком от всех, это его система макросов. К несчастью, слово "макрос" описывает множество вещей в компьютерных науках, к которым макросы Common Lisp имеют неявное и метафорическое отношение FIXME. Это приводит к бесконечным недопониманиям, когда адепты Лиспа пытаются объяснить другим, насколько макросы замечательны. [92]

Чтобы понять макросы Лиспа, необходимо подойти к делу со свободной головой, без предубеждений, основанных на других вещах, которые также оказались названными словом "макросы". Итак, давайте начнём нашу тему с шага назад и обзора различных путей, которыми создаются расширения в языках.

Всем программистам должна быть привычна идея о том, что определение языка может включать стандартную библиотеку функций, которая строится на "ядре" языка библиотеку, которая могла бы быть написана посредством языка любым программистом, если бы она не была определена как часть стандартной библиотеки. Стандартная библиотека языка Си, например, может быть написана почти полностью на переносимом Си. Аналогично, большая часть всё растущего набора классов и интерфейсов, которые поставляются в стандартном наборе Java Development Kit (JDK), написаны на "чистом" Java.

Одним из преимуществ определения языков в терминах "ядро плюс стандартная библиотека", это лёгкость в понимании и воплощении. Однако реальная выгода заключается в выразительности многое из того, про что вы думаете как про "язык", на самом деле просто библиотека язык легко расширять. Если в Си нет функции для той или иной необходимой вам задачи, вы можете её написать и теперь у вас есть слегка улучшенная версия Си. Точно так же в языках, таких как Java или Smalltalk, где почти все интересные части "языка" определены в терминах классов, определяя новый класс, вы расширяете язык, делая его более подходящим для написания программ, делающих то, что вам надо.

В то время, как Common Lisp поддерживает оба этих метода расширения языка, макросы дают Лиспу ещё один путь. Как было упомянуто кратко в Главе 4, каждый макрос определяет свой собственный синтаксис определение того, как s-выражения, которые ему передаются, будут превращены в Лисп-формы. С помощью макросов, как части ядра языка, возможно создавать новые синтаксические управляющие конструкции, такие как WHEN , DOLIST и LOOP , а так же формы определений вроде DEFUN и DEFPARAMETER , как часть "стандартной библиотеки", вместо встраивания их в ядро. Это имеет свои последствия для реализации языка, но как программиста на Лисп, вас будет больше заботить то, что это даёт вам ещё один способ расширения языка, делая его языком, лучше подходящим для выражения решений ваших собственных программистских проблем.

В данный момент может показаться, что преимущества от наличия ещё одного пути расширения языка будет легко понять. Но по некоторой причине большое количество программистов, которые фактически не использовали макросы Лиспа и которые не задумываются о создании новых функциональных абстракций или определений новых иерархий классов для решения своих задач, панически боятся самой мысли о том, что они будут иметь возможность описания новых синтаксических абстракций. Наиболее общей причиной "макрофобии", похоже является плохой опыт от использования других "макросистем". Простой страх перед неизвестным несомненно так же играет определенную роль. Чтобы избежать макрофобических реакций, я буду постепенно вводить в предмет через обсуждение нескольких стандартных макросов, конструкций контроля, определённых в Common Lisp. Это те вещи, которые должны были быть встроены в ядро языка если бы в Лиспе не было макросов. Когда вы используете их, вам не надо беспокоиться, что они сделаны в виде макросов, но они представляют из себя хороший пример того, что вы можете сделать с помощью макросов. [93] В следующей главе я покажу вам, как вы можете определять свои собственные макросы.

<p>WHEN и UNLESS</p>

Как вы уже видели, наиболее базовую форму условного выражения если x, делай y; иначе делай z представляет специальный оператор IF , который имеет следующую базовую форму:

(if condition then-form [else-form])

condition вычисляется и, если его значение не NIL , тогда then-form выполняется и полученное значение возвращается. Иначе выполняется else-form , если она есть, и её значение возвращается. Если condition даёт NIL и нет else-form , тогда IF возвращает NIL .

(if (> 2 3) "Yup" "Nope") ==> "Nope"

(if (> 2 3) "Yup") ==> NIL

(if (> 3 2) "Yup" "Nope") ==> "Yup"

Однако, IF не является вообще-то такой уж замечательной синтаксической конструкцией, потому что then-form и else-form каждая ограничена одной лисп-формой. Это значит, что если вы хотите выполнить последовательность действий в каком-либо из этих случаев, вам надо обернуть их в какой-то другой синтаксис. Например, предположим, в середине программы спам-фильтра, вы захотите сохранить в файле сообщение, как спам, и обновить базу данных по спаму, если сообщение - спам. Вы не можете написать так:

(if (spam-p current-message)

(file-in-spam-folder current-message)

(update-spam-database current-message))

потому что вызов update-spam-database будет принят за случай else , а не как часть ветви then . Другой специальный оператор PROGN , выполняет любое число форм по порядку и возвращает значение последней формы. Так что вы могли бы получить желаемое, записав всё следующим образом:

(if (spam-p current-message)

(progn

(file-in-spam-folder current-message)

(update-spam-database current-message)))

Это не так уж и ужасно. Однако, учитывая количество раз, когда вам придётся использовать эту идиому, не трудно представить себе, что через некоторое время это станет утомительно. "Почему", вы можете спросить у себя, "Лисп не предоставляет возможность выразить то, что на самом деле мне надо, скажем 'Если x верно, делай то, то и ещё вот это'?" Другими словами, через некоторое время, вы заметите повторяемость сочетания IF плюс PROGN и захотите как-то абстрагироваться от этих деталей, вместо того, чтобы каждый раз заново переписывать их.

Это как раз то, что предоставляют макросы. В данном случае, Коммон Лисп поставляется со стандартным макросом WHEN , с которым всё можно написать так:

(when (spam-p current-message)

(file-in-spam-folder current-message)

(update-spam-database current-message))

Но если бы он не был встроен в стандартную библиотеку, вы могли бы определить WHEN самостоятельно, как макрос вот так, используя запись с обратной кавычкой, которую я обсуждал в Главе 3: [94]

(defmacro when (condition &rest body)

`(if ,condition (progn ,@body)))

Сопутствующим макросу WHEN является UNLESS , который оборачивает условие, выполняя формы из тела, только если условие ложно. Другими словами:

(defmacro unless (condition &rest body)

`(if (not ,condition) (progn ,@body)))

Конечно, это довольно тривиальные макросы. Тут нет никакой страшной чёрной магии; они просто абстрагируют некоторые детали языковой бухгалтерии, позволяя вам выражать свои намерения немного более ясно. Но их тривиальность имеет важное значение: так как система макросов встроена в язык, вы можете писать тривиальные макросы вроде WHEN и UNLESS , которые дают вам небольшую, но реальную выгоду в ясности, которая затем умножается в тысячу раз когда вы используете их. В Главах 24, 26 и 31 вы увидите, как макросы могут быть использованы для серьёзных вещей, создавая целый предметно-ориентированный (domain-specific), встроенный язык. Но сначала, давайте закончим наше обсуждение стандартных макросов управления.

<p>COND</p>

Другой раз, когда непосредственное IF выражение может оказаться ужасным, это когда у вас есть условное выражение с множественными ветвлениями: если A, делай X, иначе, если B, делай Y; иначе делай Z . Нет никакой логической проблемы в написании такой цепочки условных выражений с IF , но получится не очень красиво.

(if a

(do-x)

(if b

(do-y)

(do-z)))

И это будет выглядеть ещё более ужасно, если вам понадобится включить множество форм для then случаев, привлекая несколько PROGN . Так что ничего удивительного, что Коммон Лисп предоставляет макрос для выражения условия с множеством ветвлений: COND . Вот базовый вид:

(cond

(test-1 form*)

.

.

.

(test-N form*))

Каждый элемент в теле представляет одну ветвь условия и состоит из списка, содержащего форму условия и ноль или более форм для выполнения, если выбрана эта ветвь. Условия вычисляются в том порядке, в каком расположены ветви до тех пор, пока одно из них не даст истину. В этой точке, оставшиеся формы из ветви выполняются и значение последней формы ветви возвращается как результат работы всего COND . Если ветвь не содержит форм после условия, то возвращается само значение условия. По соглашению, ветвь представляющая последний случай else в цепочке if/else-if записывается с условием T . Подойдёт любое не- NIL значение, но T служит дорожным знаком при чтении кода. Таким образом вы можете записать предыдущее вложенное IF выражение, используя COND , вот так:

(cond (a (do-x))

(b (do-y))

(t (do-z)))

<p>AND, OR и NOT</p>

При написании условий в IF , WHEN , UNLESS и COND формах, три оператора оказываются очень полезны, это булевы логические операторы AND , OR и NOT .

NOT - это функция, которая строго говоря не относится к этой главе, но она очень тесно связана с AND и OR . Она берёт свой аргумент и обращает его значение истинности, возвращая T , если аргумент NIL и NIL в ином случае.

AND и OR , однако являются макросами. Они представляют логические конъюнкцию и дизъюнкцию произвольного числа подформ и определены как макросы, так что они оптимальны в выполнении. Это значит, что они вычисляют ровно столько своих подформ, в порядке слева направо, сколько необходимо для конечного значения. То есть AND останавливается и возвращает NIL сразу же, как только одна из подформ выдаст NIL . Если все подформы выдают не- NIL результат, она возвращает значение последней подформы. OR , с другой стороны, останавливается, как только одна из подформ выдаст не- NIL и возвращает полученное значение. Если ни одна из подформ не выдаст истину, OR возвращает NIL . Вот несколько примеров:

(not nil) ==> T

(not (= 1 1)) ==> NIL

(and (= 1 2) (= 3 3)) ==> NIL

(or (= 1 2) (= 3 3)) ==> T

<p>Циклы</p>

Циклические конструкции представляют собой важный тип управляющих конструкций(FIXME в оригинале сказано наоборот). Циклические средства в Коммон Лисп, в дополнение к мощности и гибкости, являются интересным уроком по программированию в стиле "получить всё и сразу", который позволяют макросы.

Как оказалось, ни один из 25 специальных операторов Лиспа не поддерживает напрямую структуру циклов. Все циклические конструкции контроля в Лиспе - это макросы, построенные на двух специальных операторах, которые представляют собой примитивное goto средство. [95] Как многие хорошие абстракции, синтаксические или нет, циклические макросы в Лиспе построены как набор слоёв абстракций, начиная с основы, которой являются те два специальных оператора.

В самом низу (оставляя в стороне специальные операторы) находится наиболее общая конструкция контроля DO . Хотя и очень мощный, DO страдает, как и многие абстракции общего назначения, от черезмерности для простых ситуаций. Так что Лисп предоставляет два других макроса DOLIST and DOTIMES , которые менее гибки, чем DO , но лучше поддерживают наиболее распространённые случаи цикла по элементам списка или цикла с подсчётом. Хотя реализация может реализовать эти макросы как ей угодно, обычно они реализованы как макросы, которые раскрываются в соответствующий DO цикл. Таким образом DO предоставляет базовую структурную конструкцию цикла поверх нижележащих примитивов, представленных специальными операторами Коммон Лиспа, а DOLIST и DOTIMES представляют две лёгких в использовании, хотя и менее общие конструкции. И, как вы увидите в следующей главе, вы можете строить свои собственные конструкции цикла поверх DO в ситуациях, где DOLIST и DOTIMES вам не подходят.

Наконец, макрос LOOP представляет собой полномасштабный мини-язык для выражения циклических конструкций на не Лиспо-, а англо-подобном (или, как минимум, Алголо-подобном) языке. Некоторые хакеры Лиспа любят LOOP ; другие ненавидят его. Фанаты LOOP любят его за то, что он предоставляет краткий способ выразить определённые, обычно необходимые циклические конструкции. Его недоброжелатели не любят его, потому что он недостаточно похож на остальной Лисп. Однако, к какому бы лагерю вы не примкнули, это замечательный пример возможностей макросов добавлять новые конструкции в язык.

<p>DOLIST и DOTIMES</p>

Я начну с лёгких для использования DOLIST и DOTIMES макросов.

DOLIST проходит по всем элементам списка, выполняя тело цикла с переменной, содержащей последовательно элементы списка. [96] Вот базовый скелет (оставляя некоторые эзотерические опции):

(dolist (var list-form)

body-form*)

Когда цикл стартует, list-form выполняется один раз, чтобы создать список. Затем тело цикла выполняется для каждого элемента в списке, с переменной var , содержащей значение элемента. Например:

CL-USER> (dolist (x '(1 2 3)) (print x))

1

2

3

NIL

Использованная таким образом, форма DOLIST , в целом, возвращает NIL .

Если вы хотите прервать цикл DOLIST до окончания списка, можете использовать RETURN .

CL-USER> (dolist (x '(1 2 3)) (print x) (if (evenp x) (return)))

1

2

NIL

DOTIMES - это конструкция цикла верхнего уровня для циклов с подсчётом. Основной вид более-менее такой же, как у DOLIST .

(dotimes (var count-form)

body-form*)

count-form должна выдать целое число. Каждый раз, в процессе цикла, var содержит последовательные целые от 0 до на единицу меньшего, чем то число. Например:

CL-USER> (dotimes (i 4) (print i))

0

1

2

3

NIL

Так же, как и с DOLIST , вы можете использовать RETURN , чтобы прервать цикл раньше.

Так как тела обоих DOLIST и DOTIMES циклов могут содержать любые типы выражений, вы так же можете делать циклы вложенными. Например, чтобы напечатать таблицу умножения от 1 x 1 = 1 да 20 x 20 = 400, вы можете написать такую пару вложенных циклов DOTIMES :

(dotimes (x 20)

(dotimes (y 20)

(format t "~3d " (* (1+ x) (1+ y))))

(format t "~%"))

<p>DO</p>

Хотя DOLIST и DOTIMES удобны и легки в использовании, они недостаточно гибки, чтобы использоваться для любых циклов. Например, что если вы захотите менять на каждом шаге несколько переменных параллельно? Или использовать произвольное выражение для проверки окончания цикла? Если ни DOLIST , ни DOTIMES не подходят для ваших целей, у вас всё ещё есть доступ к наиболее общему циклу DO .

Там, где DOLIST и DOTIMES предоставляют только одну переменную цикла, DO позволяет вам держать любое число переменных и даёт вам полный контроль над тем, как они будут изменяться на каждом шаге цикла. Вы так же определяете проверку, которая говорит когда циклу завершиться и может предоставлять форму для вычисления в конце цикла, чтобы сформировать возвращаемое значение для всего DO в целом. Базовый шаблон выглядит так:

(do (variable-definition*)

(end-test-form result-form*)

statement*)

Каждое variable-definition (определение переменной) вводит переменную, которая будет в поле видимости тела цикла. Полная форма одного определения переменной, это список, содержащий три элемента.

(var init-form step-form)

init-form будет выполнена в начале цикла и полученное значение присвоено переменной var . Перед каждой последующей итерацией цикла, step-form будет выполнена и её значение присвоено var . Форма step-form необязательна; если её не будет, переменная останется с тем же значением от итерации к итерации, пока вы прямо не назначите ей новое значение в теле цикла. Так же, как и с присвоением переменным значений в LET , если форма init-form не задана, переменной присваивается NIL . Так же, как и в LET , вы можете использовать просто имя переменной, вместо списка, содержащего только имя.

В начале каждой итерации, после того, как все переменные цикла получили свои новые значения, выполняется форма end-test-form . До тех пор, пока она вычисляется в NIL , итерация происходит, выполняя statement по порядку.

Когда вычисление формы end-test-form выдаст истину, будет вычислена форма result-forms и значение, полученное в результате, будет возвращено как значение всего выражения DO .

На каждом шаге итерации, шаговые формы для переменных вычисляются прежде придания новых значений этим переменным. Это значит, что вы можете ссылаться на любую другую переменную внутри шаговых форм. [97] Таким образом, цикл выглядит так:

(do ((n 0 (1+ n))

(cur 0 next)

(next 1 (+ cur next)))

((= 10 n) cur))

шаговые формы (1+ n) , next , и (+ cur next) все вычисляются, использую старое значение n , cur и next . Только после вычисления всех шаговых форм, переменные получают свои новые значения. (Математически образованные читатели могут заметит, что это частично эффективный способ подсчёта одиннадцатого числа Фибоначчи.)

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

(do (variable-definition*)

(end-test-form result-form*)

statement*)

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

**(**do **(**(i 0 (1+ i))**)**

**(**(>= i 4)**)**

(print i)**)**

(DELETEME - не знаю как выделить жирным скобки в коде)

Заметьте, что форма для результата пропущена. Это, однако, не особо распространённое использование DO , так как такой цикл гораздо проще написать используя DOTIMES . [98]

(dotimes (i 4) (print i))

Другой пример, в котором отсутствует тело цикла для вычисления чисел Фибоначчи:

**(**do **(**(n 0 (1+ n))

(cur 0 next)

(next 1 (+ cur next))**)**

**(**(= 10 n) cur**))**

(DELETEME - не знаю как выделить жирным скобки в коде)

Наконец, следующий пример демонстрирует цикл DO , в котором нет привязанных переменных. Он крутится, пока текущее время меньше, чем значение глобальной переменной, печатая "Waiting" каждую минуту. Заметьте, что даже без переменных цикла, вы всё равно нуждаетесь в пустом списке для списка переменных.

**(**do **()**

**(**(> (get-universal-time) *some-future-date*)**)**

(format t "Waiting~%")

(sleep 60)**)**

(DELETEME - не знаю как выделить жирным скобки в коде)

<p>Всемогущий LOOP</p>

Для простых случаев у вас есть DOLIST и DOTIMES . И если они не удовлетворяют вашим нуждам, вы можете вернуться к совершенно общему DO . Чего ещё можно хотеть?

Однако оказывается, что удобные идиомы для циклов появляются снова и снова, такие, как циклы по элементам различных структур с данными: списков, векторов, хэш-таблиц и пакетов, либо накопление значений разными способами в процессе цикла: собирание, подсчёт, суммирование, минимизация или максимизация. Если вы нуждаетесь в цикле, который бы делал одну из этих вещей (или несколько одновременно), макрос LOOP может предоставить вам простой путь это выразить.

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

(loop

body-form*)

Формы внутри тела выполняются каждый раз в процессе цикла, который будет длиться вечно, пока вы не используете RETURN , чтобы прервать его. Например, вы могли бы записать предыдущий DO цикл с простым LOOP .

(loop

(when (> (get-universal-time) *some-future-date*)

(return))

(format t "Waiting~%")

(sleep 60))

Расширенный LOOP несколько иной зверь. Он отличается использованием специальных ключевых слов цикла, которые представляют язык специального назначения для выражения циклических идиом. FIXME(Это не по русски:) Это ничего не значит, что не все Лисповоды любят язык расширенного LOOP . Как минимум один из создателей Коммон Лиспа ненавидит его. Критики LOOP жалуются, что его синтаксис совсем не лисповский. (другими словами, в нём недостаточно скобок). Любители LOOP замечают, что в этом то и весь смысл: сложные циклические конструкции достаточно тяжелы для восприятия и без заворачивания их в туманный синтаксис DO . Лучше, говорят они, иметь немного более наглядный синтаксис, который давал бы вам какие-то подсказки о том, что происходит.

Вот, например, характерный DO цикл, который собирает числа от 1 до 10 в список:

(do ((nums nil) (i 1 (1+ i)))

((> i 10) (nreverse nums))

(push i nums)) ==> (1 2 3 4 5 6 7 8 9 10)

У опытного Лиспера не будет никаких проблем понять этот код это просто вопрос понимания основной формы DO цикла и распознания PUSH / NREVERSE идиомы для построения списка. Но это не совсем прозрачно. Версия с LOOP , с другой стороны, почти понятна как предложение на английском. (цикл по i от 1 до 10, собирая i )

(loop for i from 1 to 10 collecting i) ==> (1 2 3 4 5 6 7 8 9 10)

Далее, ещё несколько примеров простого использования LOOP . Вот сумма квадратов первых десяти чисел:

(loop for x from 1 to 10 summing (expt x 2)) ==> 385

Это подсчёт числа гласных в строке:

(loop for x across "the quick brown fox jumps over the lazy dog"

counting (find x "aeiou")) ==> 11

Вот вычисление одиннадцатого числа Фибоначчи, аналогично использованному ранее циклу DO :

(loop for i below 10

and a = 0 then b

and b = 1 then (+ b a)

finally (return a))

Символы across , and , below , collecting , counting , finally , for , from , summing , then и to являются некоторыми из ключевых слов цикла, чьё присутствие обозначает, что перед нами расширенная версия LOOP . [99]

Я приберегу подробности о LOOP для Главы 22, однако сейчас стоит заметить, что это ещё один пример того, как макросы могут быть использованы для расширения основы языка. В то время как LOOP предоставляет свой собственный язык для выражения циклических конструкций, он никак не отрезает вас от остального Лиспа. Ключевые слова LOOP разбираются в соответствии с его грамматикой, но остальной код внутри LOOP , это обычный Лисп-код.

И так же стоит отметить ещё раз, что хотя макрос LOOP гораздо более сложный, чем WHEN или UNLESS , он просто ещё один макрос. Если бы он не был включён в стандартную библиотеку, вы могли бы сделать это сами или взять стороннюю библиотеку, которая это сделает.

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

8
<p>8. Макросы: Создание собственных макросов</p>

Теперь пора начать писать свои собственные макросы. Стандартные макросы, описанные мною в предыдущей главе, должны были дать вам некоторое представление о том, что вы можете сделать при помощи макросов, но это было только начало. Поддержка макросов в Common Lisp не является чем-то большим, чем поддержка функций в C, и поэтому каждый программист на Lisp может создать свои собственные варианты стандартных конструкций контроля точно так же, как каждый программист на C может написать простые варианты функций из стандартной библиотеки C. Макросы являются частью языка, которая позволяет вам создавать абстракции поверх основного языка и стандартной библиотеки, что приближает вас к возможности непосредственного выражения того, что вы хотите выразить.

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

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

<p>История Мака: обычная такая история</p>

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

В отчаянии большой босс нанял младшего (junior) программиста, Мака, чьей работой стал поиск комментариев, написание требуемого кода и вставка его в программу на место комментариев. Мак никогда не запускал программы, ведь они не были завершены и поэтому он попросту не мог этого сделать. Но даже если бы они были завершены, Мак не знал, какие данные необходимо подать на их вход. Поэтому он просто писал свой код, основываясь на содержимом комментариев, и посылал его назад создавшему комментарий программисту.

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

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

Следующее новшество появилось, когда программист вставил в самый верх одной из своих программ комментарий, содержащий определение функции и пояснение, гласившее: "Мак, не пиши здесь никакого кода, но сохрани эту функцию на будущее; я собираюсь использовать ее в некоторых своих комментариях." Другие комментарии в этой программе гласили следующее: "Мак, замени этот комментарий на результат выполнения той функции с символами x и y как аргументами."

Этот метод распространился так быстро, что в течение нескольких дней большинство программ стало содержать дюжины комментариев с описанием функций, которые использовались только кодом в других комментариях. Чтобы облегчить Маку различение комментариев, содержащих только определения и не требующих немедленного ответа, программисты отмечали их стандартным предисловием: "Definition for Mac, Read Only" (Определение для Мака, только для чтения). Это (как мы помним, программисты были очень ленивы) быстро сократилось до "DEF. MAC. R/O", а потом до "DEFMACRO".

Очень скоро в комментариях для Мака вообще не осталось английского. Целыми днями он читал и отвечал на электронные письма от компилятора, содержащие DEFMACRO комментарии и вызывал функции, описанные в DEFMACRO. Так как Lisp программы в комментариях осуществляли всю реальную работу, то работа с электронными письмами перестала быть проблемой. У Мака внезапно стало много свободного времени, и он сидел в своем кабинете и грезил о белых песчаных пляжах, чистой голубой океанской воде и напитках с маленькими бумажными зонтиками.

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

<p>Время раскрытия макросов против времени выполнения</p>

Ключом к пониманию макросов является полное понимание разницы между кодом, генерирующем код (макросами), и кодом, который в конечном счете выполняет программу (все остальное). Когда вы пишете макросы, вы пишете программы, которые будут использоваться компилятором для генерации кода, который затем будет скомпилирован. Только после того, как все макросы будут полностью раскрыты, а полученный код скомпилирован, программа сможет быть запущена. Время, когда выполняются макросы, называется временем раскрытия макросов ; оно отлично от времени выполнения , когда выполняется обычный код, включая код, сгенерированный макросами.

Очень важно полностью понимать это различие, так как код, работающий во время раскрытия макросов, запускается в окружении, сильно отличающемся от окружения кода, работающего во время выполнения. А именно, во время раскрытия макросов не существует способа получить доступ к данным, которые будут существовать во время выполнения. Подобно Маку, который не мог запускать программы, над которыми он работал, так как не знал, что является корректным входом для них, код, работающий во время раскрытия макросов, может работать только с данными, являющимися неотъемлемой частью исходного кода. Для примера предположим, что следующий исходный код появляется где-то в программе:

(defun foo (x)

(when (> x 10) (print 'big)))

Обычно вы бы думали о x как о переменной, которая будет содержать аргумент, переданный при вызове foo. Но во время раскрытия макросов (например когда компилятор выполняет макрос WHEN ) единственными доступными данными является исходный код. Так как программа пока не выполняется, нет вызова foo и, следовательно, нет значения, ассоциированного с x . Вместо этого значения, которые компилятор передает в WHEN , являются списками Lisp, представляющими исходный код, а именно (> x 10) и (print 'big) . Предположим, что WHEN определен, как вы видели в предыдущей главе, подобным образом:

(defmacro when (condition &rest body)

`(if ,condition (progn ,@body)))

При компиляции кода foo макрос WHEN будет запущен с этими двумя формами в качестве аргументов. Параметр condition будет связан с формой (> x 10) , а форма (print 'big) будет собрана (will be collected) в список (и будет его единственным элементом), который станет значением параметра &rest body . Выражение квазицитирования затем сгенерирует следующий код:

(if (> x 10) (progn (print 'big)))

подставляя значение condition , а также вклеивая значение body в PROGN .

Когда Lisp интерпретируется, а не компилируется, разница между временем раскрытия макросов и временем выполнения менее очевидна, так как они "переплетены" во времени (temporally intertwined). Также стандарт языка не специфицирует в точности того, как интерпретатор должен обрабатывать макросы: он может раскрывать все макросы в интерпретируемой форме, а затем интерпретировать полученный код, или же он может начать с непосредственно интерпретирования формы и раскрывать макросы при их встрече. В обоих случаях макросам всегда передаются невычисленные объекты Lisp, представляющие подформы формы макроса, и задачей макроса все также является генерирование кода, который затем осуществит какие-то действия, а не непосредственное осуществление этих действий.

<p>DEFMACRO</p>

Как вы видели в главе 3, макросы на самом деле определяются с помощью форм DEFMACRO , что означает, разумеется, "DEFine MACRO", а не "Definition for Mac". Базовый шаблон DEFMACRO очень похож на шаблон DEFUN .

(defmacro name (parameter*)

"Optional documentation string."

body-form*)

Подобно функциям, макрос состоит из имени, списка параметров, необязательной строки документации и тела, состоящего из выражений Lisp [100]. Однако, как я только что говорил, работой макроса не является осуществление какого-то действия напрямую, его работой является генерирование кода, который затем сделает то, что вам нужно.

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

Задачей макроса является преобразование формы макроса (другими словами, формы Lisp, первым элементом которой является имя макроса) в код, который осуществляет определенные действия. Иногда вы пишете макрос начиная с того кода, который вы бы хотели иметь возможность писать, то есть с примера формы макроса. В другой раз вы решаете написать макрос после того, как вы использовали какой-то образец кода несколько раз и понимаете, что можете сделать ваш код чище путем абстрагирования этого образца.

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

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

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

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

1. Написание примера вызова макроса, а затем кода, в который он должен быть раскрыт (или в обратном порядке).

2. Написание кода, генерирующего написанный вручную код раскрытия по аргументам в примере вызова.

3. Проверка того, что предоставляемамя макросом абстракция не "протекает".

<p>Пример макроса: do-primes</p>

Для того, чтобы увидеть, как этот трехшаговый процесс осуществляется, вы напишете макрос do-primes , который предоставляет конструкцию итерирования, подобную DOTIMES и DOLIST , за исключением того, что вместо итерирования по целым числам или элементам списка итерирование будет производиться по последовательным простым числам. Этот пример не является примером чрезвычайно полезного макроса, он всего лишь средство демонстрации вышеописанного процесса.

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

(defun primep (number)

(when (> number 1)

(loop for fac from 2 to (isqrt number) never (zerop (mod number fac)))))

(defun next-prime (number)

(loop for n from number when (primep n) return n))

Теперь вы можете написать макрос. Следуя процедуре, очерченной выше, вам нужен по крайней мере один пример вызова макроса и кода, в который он должен быть раскрыт. Предположим, что вы начали с мысли о том, что хотите иметь возможность написать следующее:

(do-primes (p 0 19)

(format t "~d " p))

для выражения цикла, который выполняет тело для каждого простого числа, большего либо равного 0 и меньшего либо равного 19, используя переменную p для хранения очередного простого числа. Имеет смысл смоделировать этот макрос с помощью стандартных макросов DOLIST и DOTIMES ; макрос, следующий образцу существующих макросов, легче понять и использовать, нежели макросы, которые вводят неоправданно новый синтаксис.

Без использования макроса do-primes вы можете написать такой цикл путем использования DO (и двух вспомогательных функций, определенных ранее) следующим образом:

(do ((p (next-prime 0) (next-prime (1+ p))))

((> p 19))

(format t "~d " p))

Теперь вы готовы к написанию кода макроса, который будет выполнять необходимое преобразование.

<p>Макропараметры</p>

Так как аргументы, передаваемые в макрос, являются объектами Lisp, представляющими исходный код вызова макроса, первым шагом любого макроса является извлечение тех частей этих объектов, которые нужны для вычисления раскрытия. Для макросов, которые просто подставляют свои аргументы напрямую в шаблон, этот шаг тривиален: подходит простое определение правильных параметров для захвата нужных аргументов.

Но, кажется, такого подхода недостаточно для do-primes . Первый аргумент вызова do-primes является списком, содержащим имя переменной цикла, p ; нижнюю границу, 0; верхнюю границу, 19. Но, если вы посмотрите на раскрытие, список, как целое, не встречается в нем: эти три элемента разделены и вставлены в различные места.

Вы можете определить do-primes с двумя параметрами, первый для захвата этого списка и параметр &rest для захвата форм тела цикла, а затем разобрать первый список вручную подобным образом:

(defmacro do-primes (var-and-range &rest body)

(let ((var (first var-and-range))

(start (second var-and-range))

(end (third var-and-range)))

`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))

((> ,var ,end))

,@body)))

Очень скоро я объясню как тело макроса генерирует правильное раскрытие; сейчас же вам следует отметить, что переменные var , start и end , каждая содержит значение, извлеченное из var-and-range , и эти значения затем подставляются в выражение квазицитирования, генерирующее раскрытие do-primes .

Однако, вам не нужно разбирать var-and-range вручную, так как список параметров макроса является так называемым списком деструктурируемых параметров. Деструктурирование, как и говорит название, осуществляет разбор некоторой структуры, в нашем случае списочной структуры форм, переданных макросу.

Внутри списка деструктурируемых параметров простое имя параметра может быть заменено вложенным списком параметров. Параметры в таком списке будут получать свои значения из элементов выражения, которое было бы связано с параметром, замененным этим списком. Например, вы можете заменить var-and-range списком (var start end) и три элемента списка будут автоматически деструктурированы в эти три параметра.

Другой особенностью списка параметров макросов является то, что вы можете использовать &body как синоним &rest . Семантически &body и &rest эквиваленты, но множество сред разработки будут использовать факт наличия параметра &body для изменения того, как они будут выравнивать код использования макроса, поэтому обычно параметры &body используются для захвата списка форм, которые составляют тело макроса.

Таким образом, вы можете улучшить определение макроса do-primes и дать подсказку как людям, читающим ваш код, так и вашим инструментам разработки, об его предназначении следующим образом

(defmacro do-primes ((var start end) &body body)

`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))

((> ,var ,end))

,@body))

В стремлении к краткости список деструктурируемых параметров также предоставляет вам автоматическую проверку ошибок: при определении таким образом do-primes , Lisp будет способен определять вызовы, в которых первый аргумент не является трехэлементным списком, и выдавать вам разумные сообщения об ошибках как когда вы вызываете функцию со слишком малым или, наоборот, слишком большим числом аргументов. Также, среды разработки, такие как SLIME, указывающие вам какие аргументы ожидаются, как только вы напечатаете имя функции или макроса, при использовании вами списка деструктурируемых параметров будут способны более конкретно указать синтаксис вызова макроса. С исходным определением SLIME будет подсказывать вам, что do-primes вызывается подобным образом:

(do-primes var-and-range &rest body)

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

(do-primes (var start end) &body body)

Списки деструктурируемых параметров могут содержать параметры &optional , &key и &rest , а также вложенные деструктурируемые списки. Однако все эти возможности не нужны вам для написания do-primes .

<p>Генерация раскрытия</p>

Так как do-primes является довольно простым макросом, после деструктурирования аргументов все, что вам остается сделать это подставить их в шаблон для получения раскрытия.

Для простых макросов, наподобие do-primes , лучшим вариантом является использование специального синтаксиса квазицитирования. Коротко говоря, выражения квазицитирования подобны выражениям цитирования, за исключеним того, что вы можете "раскавычить" определенные подвыражения, предваряя их запятой, за которой возможно следует знак "at" (@). Без этого знака "at" запятая вызывает включение как есть значения следующего за ней подвыражения. Со знаком "at" значение, которое должно быть списком, "вклеивается" в окружающий список.

Другой пригодный способ думать о синтаксисе квазицитирования как об очень кратком способе написания кода, генерирующего списки. Такое представление о нем имеет преимущество, так как является очень близким к тому, что на самом деле происходит "под капотом": когда процедура чтения считывает выражение квазицитирования, она преобразует его в код, который генерирует соответствующую списковую структуру. Например, `(,a b) вероятно будет прочитано как (list a 'b). Стандарт языка не указывает, какой в точности код процедура чтения должна выдавать, пока она генерирует правильные списковые структуры.

Таблица 8-1 показывает некоторые примеры выражений квазицитирования вместе с эквивалентным создающим списки кодом, а также результаты, которые вы получите при вычислении как выражений квазицитирования, так и эквивалентного кода [101]:

Таблица 8-1. Примеры квазицитирования ^ Синтаксис квазицитирования Эквивалентый создающий списки код Результат | `(a (+ 1 2) c) (list 'a '(+ 1 2) 'c) (a (+ 1 2) c) | `(a ,(+ 1 2) c) (list 'a (+ 1 2) 'c) (a 3 c) | `(a (list 1 2) c) (list 'a '(list 1 2) 'c) (a (list 1 2) c) | `(a ,(list 1 2) c) (list 'a (list 1 2) 'c) (a (1 2) c) | `(a ,@(list 1 2) c) (append (list 'a) (list 1 2) (list 'c)) (a 1 2 c) |

Важно заметить, что нотация квазицитирования является просто удобством. Но это большое удобство. Для оценки того, насколько оно велико, сравните версию do-primes с квазицитированием со следующей версией, которая явно использует создающий списки код:

(defmacro do-primes-a ((var start end) &body body)

(append '(do)

(list (list (list var

(list 'next-prime start)

(list 'next-prime (list '1+ var)))))

(list (list (list '> var end)))

body))

Как вы очень скоро увидите, текущая реализация do-primes не обрабатывает корректно некоторые граничные случаи. Но первое, что вы должны проверить, это то, что она по крайней мере работает для исходного примера. Вы можете сделать это двумя способами. Во-первых, вы можете косвенно протестировать свою реализацию просто воспользовавшись ею (подразумевая, что если итоговое поведение корректно, то и раскрытие также корректно). Например, вы можете напечатать исходный пример использования do-primes в REPL и увидеть, что он и в самом деле напечатает правильную последовательность простых чисел.

CL-USER> (do-primes (p 0 19) (format t "~d " p))

2 3 5 7 11 13 17 19

NIL

Или же вы можете проверить макрос напрямую, посмотрев на раскрытие определенного вызова. Функция MACROEXPAND-1 получает любое выражение Lisp в качестве аргумента и возвращает результат осуществления одного шага раскрытия макроса [102]. Так как MACROEXPAND-1 является функцией, для дословной передачи ей формы макроса вы должны зацитировать эту форму. Теперь вы можете воспользоваться MACROEXPAND-1 для просмотра раскрытия предыдущего вызова [103].

CL-USER> (macroexpand-1 '(do-primes (p 0 19) (format t "~d " p)))

(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))

((> P 19))

(FORMAT T "~d " P))

T

Также, для большего удобства, в SLIME вы можете проверить раскрытие макроса поместив курсор на открывающую скобку формы макроса в вашем исходном коде и набрав C-c RET для вызова функции Emacs slime-macroexpand-1 , которая передаст форму макроса в MACROEXPAND-1 и напечатает результат во временном буфере.

Теперь вы можете видеть, что результат раскрытия макроса совпадает с исходным написанным вручную раскрытием, и поэтому кажется, что do-primes работает.

<p>Устранение протечек</p>

В своем эссе "Закон дырявых абстракций" Джоэл Спольски придумал термин "дырявой абстракции" для описания такой абстракции, через которую "протекают" детали, абстрагирование от которых предполагается. Так как написание макроса это способ создания абстракции, вам следует убедиться, что ваш макрос излишне не "протекает" [104]

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

Текущее определение страдает от одной из трех возможных "протечек" макросов, а именно, оно вычисляет подформу end слишком много раз. Предположим, что вы вызвали do-primes с таким выражением, как (random 100) , на месте параметра end вместо использования числового литерала, такого, как 19.

(do-primes (p 0 (random 100))

(format t "~d " p))

Предполагаемым поведением здесь является итерирование по простым числам от нуля до какого-то случайного простого числа, возвращенного (random 100) . Однако, это не то, что делает текущая реализация, как это показывает MACROEXPAND-1 .

CL-USER> (macroexpand-1 '(do-primes (p 0 (random 100)) (format t "~d " p)))

(DO ((P (NEXT-PRIME 0) (NEXT-PRIME (1+ P))))

((> P (RANDOM 100)))

(FORMAT T "~d " P))

T

При запуске кода раскрытия RANDOM будет вызываться при каждой проверке условия окончания цикла. Таким образом, вместо итерирования, пока p не станет больше, чем изначально выбранное случайное число, этот цикл будет осуществляться пока не случится, что выбранное в очередной раз случайное число окажется меньше текущего значения p . Хотя общее число итераций по прежнему случайно, оно будет подчиняться вероятностному распределению, отличному от равномерного распределения результатов RANDOM .

Это является "протечкой" абстракции, так как для корректного использования макроса, его пользователь должен быть осведомлен о том, что форма end будет вычислять более одного раза. Одним из способов устранения этой "протечки" является простое специфицирование ее как поведения do-primes . Но это не достаточно удовлетворительно: при реализации макросов вам следует пытаться соблюдать Правило Наименьшего Удивления. К тому же программисты обычно ожидают, что формы, которые они передают макросам, будут вычисляться не большее число раз, чем это действительно необходимо [105]. Более того, так как do-primes построена на основе модели стандартных макросов DOTIMES и DOLIST , которые вычисляют однократно все свои формы, кроме форм тела, то большинство программистов будут ожидать от do-primes подобного поведения.

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

(defmacro do-primes ((var start end) &body body)

`(do ((ending-value ,end)

(,var (next-prime ,start) (next-prime (1+ ,var))))

((> ,var ending-value))

,@body))

К сожалению данное исправление вводит две новые "протечки" в предоставляемую нашим макросом абстракцию.

Одна из этих "протечек" подобна проблеме множественных вычислений, которую мы только что исправили. Так как формы инициализации переменных цикла DO вычисляются в том порядке, в каком переменные определены, то когда раскрытие макроса вычисляется, выражение, переданное как end , будет вычислено перед выражением, переданным как start , то есть в обратном порядке от того, как они идут в вызове макроса. Эта "протечка" не вызывает никаких проблем пока start и end являются литералами вроде 0 и 19. Но, если они являются формами, которые могут иметь побочные эффекты, вычисление их в неправильном порядке снова нарушает Правило Наименьшего Удивления.

Эта "протечка" устраняется тривиально путем изменения порядка определения двух переменных.

(defmacro do-primes ((var start end) &body body)

`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))

(ending-value ,end))

((> ,var ending-value))

,@body))

Последняя "протечка", которую нам нужно устранить, была создана использованием имени переменной end-value . Проблема заключается в том, что имя, которое должно быть полностью внутренней деталью реализации макроса, может вступить во взаимодействие с кодом, переданным макросу, или с контекстом, в котором макрос вызывается. Следующий, кажущийся вполне допустимым, вызов do-primes не работает корректно из-за данной "протечки":

(do-primes (ending-value 0 10)

(print ending-value))

То же касается и следующего вызова:

(let ((ending-value 0))

(do-primes (p 0 10)

(incf ending-value p))

ending-value)

И снова MACROEXPAND-1 может вам показать, в чем проблема. Первый вызов расширяется в следующее:

(do ((ending-value (next-prime 0) (next-prime (1+ ending-value)))

(ending-value 10))

((> ending-value ending-value))

(print ending-value))

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

Второй проблемный вызов расширяется следующим образом:

(let ((ending-value 0))

(do ((p (next-prime 0) (next-prime (1+ p)))

(ending-value 10))

((> p ending-value))

(incf ending-value p))

ending-value)

В этом случае сгенерированный код полностью допустим, но его поведение совсем не то, что нужно вам. Так как привязка ending-value, установленная с помощью LET снаружи цикла перекрывается переменной с таким же именем внутри DO , то форма (incf ending-value p) увеличивает переменную цикла ending-value вместо внешней переменной с таким же именем, создавая другой вечный цикл [106].

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

Функция GENSYM возвращает уникальный символ при каждом своем вызове. Такой символ никогда до этого не был прочитан процедурой чтения Lisp и, так как он не интернирован (isn't interned) ни в один пакет, никогда не будет прочитан ею. Поэтому, вместо использования литеральных имен наподобие ending-value , вы можете генерировать новый символ при каждом раскрытии do-primes .

(defmacro do-primes ((var start end) &body body)

(let ((ending-value-name (gensym)))

`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))

(,ending-value-name ,end))

((> ,var ,ending-value-name))

,@body)))

Обратите внимание, что код, вызывающий GENSYM не является частью раскрытия; он запускается как часть процедуры раскрытия макроса и поэтому создает новый символ при каждом раскрытии макроса. Это может казаться несколько странным сначала: ending-value-name является переменной, чье значение является именем другой переменной. Но на самом деле тут нет никаких отличий от параметра var , чье значение также является именем переменной. Единственная разница состоит в том, что значение var было создано процедурой чтения, когда форма макроса была прочитана, а значение ending-value-name было сгенерированно программно при запуске кода макроса.

С таким определением две ранее проблемные формы расширяются в код, который работает так, как вам нужно. Первая форма:

(do-primes (ending-value 0 10)

(print ending-value))

расширяется в следующее:

(do ((ending-value (next-prime 0) (next-prime (1+ ending-value)))

(#:g2141 10))

((> ending-value #:g2141))

(print ending-value))

Теперь переменная, используемая для хранения конечного значения является сгенерированным функцией gensym символом, #:g2141. Имя идентификатора, G2141 , было сгенерировано с помощью GENSYM , но важно не это; важно то, что идентификатор хранит значение объекта. Сгенерированные таким образом символы печатаются в обычном синтаксисе для неинтернированных символов: с начальным #: .

Вторая ранее проблемная форма:

(let ((ending-value 0))

(do-primes (p 0 10)

(incf ending-value p))

ending-value)

после замены do-primes его раскрытием будет выглядеть подобным образом:

(let ((ending-value 0))

(do ((p (next-prime 0) (next-prime (1+ p)))

(#:g2140 10))

((> p #:g2140))

(incf ending-value p))

ending-value)

И снова, тут нет никакой "протечки", так как переменная ending-value , связанная окружающей цикл do-primes формой LET , больше не перекрывается никакими переменными, вводимыми в коде раскрытия.

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

Этим исправлением мы устранили все "протечки" в реализации do-primes . После получения некоторого опыта в написании макросов, вы научитесь писать макросы с заранее устраненными "протечками" такого рода. На самом деле это довольно просто, если вы будете следовать следующим правилам:

Если только нет определенной причины сделать иначе, включайте все подформы в раскрытие на такие позиции, чтобы они выполнялись в том же порядке, в каком они идут в вызове макроса. Если только нет определенной причины сделать иначе, убедитесь, что все подформы вычисляются лишь единожды, путем создания в раскрытии переменных для содержания значений вычисления форм аргументов и последующего использования этих переменных везде в раскрытии, где нужны значения этих форм. Используйте GENSYM во время раскрытия макросов для создания имен переменных, используемых в раскрытии.
<p>Макросы, создающие макросы</p>

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

На самом деле, вы уже видели один такой образец: многие макросы, как и последняя версия do-primes , начинаются с LET , который вводит несколько переменных, содержащих сгенерированные символы для использовании в раскрытии макроса. Так как это общий образец, почему бы нам не абстрагировать его с помощью его собственного макроса?

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

Предположим, вы хотите иметь возможность написать подобное:

(defmacro do-primes ((var start end) &body body)

(with-gensyms (ending-value-name)

`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))

(,ending-value-name ,end))

((> ,var ,ending-value-name))

,@body)))

и получить do-primes , эквивалентный его предыдущей версии. Другими словами, with-gensyms должен раскрываться в LET , которая связывает каждую перечисленную переменную, ending-value-name в данном случае, со сгенерированным символом. Достаточно просто написать это с помощью простого шаблона-квазитирования.

(defmacro with-gensyms ((&rest names) &body body)

`(let ,(loop for n in names collect `(,n (gensym)))

,@body))

Обратите внимание, как мы можем использовать запятую для подстановки значения выражения LOOP . Этот цикл генерирует список связывающих форм, каждая из которых состоит из списка, содержащего одно из переданных with-gensyms имен, а также литеральный код (gensym) . Вы можете проверить, какой код сгенерирует выражение LOOP в REPL, заменив names списком символов.

CL-USER> (loop for n in '(a b c) collect `(,n (gensym)))

((A (GENSYM)) (B (GENSYM)) (C (GENSYM)))

После списка связывающих форм в качестве тела LET вклеивается аргумент body with-gensyms . Таким образом, из кода, который вы оборачиваете в with-gensyms , вы можете ссылаться на любое из имен переменных из списка переменных, переданного with-gensyms .

Если вы воспользуетесь macro-expand для формы with-gensyms в новом определении do-primes , то вы получите подобное:

(let ((ending-value-name (gensym)))

`(do ((,var (next-prime ,start) (next-prime (1+ ,var)))

(,ending-value-name ,end))

((> ,var ,ending-value-name))

,@body))

Выглядит неплохо. Хотя этот макрос довольно прост, очень важно ясно понимать то, когда различные макросы раскрываются: когда вы компилируете DEFMACRO do-primes , форма with-gensyms раскрывается в код, который вы только что видели. Таким образом, скомпилированная версия do-primes в точности такая же, как если бы вы написали внешний LET вручную. Когда вы компилируете функцию, которая использует do-primes , то для генерации расширения do-primes запускается код, сгенерированный with-gensyms , но сам with-gensyms при компиляции формы do-primes не нужен, так как он уже был раскрыт при компиляции do-primes .

<p>Другой классический макрос, создающий макросы: ONCE-ONLY</p>

Другим классическим макросом, создающим макросы, является once-only , который используется для генерации кода, вычисляющего определенные аргументы макроса только единожды и в определенном порядке. Используя once-only вы можете написать do-primes почти таким же простым способом, как исходную "протекающую" версию, следующим образом:

(defmacro do-primes ((var start end) &body body)

(once-only (start end)

`(do ((,var (next-prime ,start) (next-prime (1+ ,var))))

((> ,var ,end))

,@body)))

Однако, реализация once-only несколько запутанна для обычного пошагового объяснения, так как зависит от множества уровней квазицитирования и "раскавычивания". Если вы действительно хотите попрактиковаться в понимании макросов, вы можете попытаться разобраться, как он работает. Макрос выглядит следующим образом:

(defmacro once-only ((&rest names) &body body)

(let ((gensyms (loop for n in names collect (gensym))))

`(let (,@(loop for g in gensyms collect `(,g (gensym))))

`(let (,,@(loop for g in gensyms for n in names collect ``(,,g ,,n)))

,(let (,@(loop for n in names for g in gensyms collect `(,n ,g)))

,@body)))))

<p>Не только простые макросы</p>

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

9
<p>Практикум: Каркас для юнит-тестирования.</p>

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

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

Главная особенность автоматизированного тестирования состоит в том, что каркас отвечает за проверку, все ли тесты выполнились успешно. Вам не требуется тратить время на то, чтобы пробираться сквозь результаты, сверяя их с ожидаемыми компьютер может сделать это гораздо быстрее и аккуратнее вас. Как следствие, каждый тест должен быть выражением, которое вырабатывает логическое значение истина или ложь, тест выполнен успешно или провалился. К примеру, если вы тестируете встроенную функцию + , следующие выражения являются вполне разумными тестами [107]:

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4)

Функции с побочными эффектами необходимо тестировать слегка по-другому вам придётся вызвать функцию и затем проверить наличие ожидаемых побочных эффектов [108]. Но в любом случае каждый тест сводится к логическому выражению: сработало или не сработало.

<p>Два первых подхода</p>

Если бы вы тестировали вручную, вы бы вводили эти выражения в REPL и проверяли бы, что они возвращают T . Но вам нужен каркас, который позволяет с лёгкостью организовывать и запускать эти тесты в любое время. Если вы хотите начать с самой простой работающей версии, вы можете просто написать функцию, которая вычисляет все тесты и возвращает T в случае успешного прохождения всех тестов (используя AND для этого).

(defun test-+ ()

(and

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4)))

Для запуска тестов просто вызовите test-+ .

CL-USER> (test-+)

T

Пока функция возвращает T , вы знаете, что тесты проходят. Такой способ организации тестов также весьма выразителен вам не нужно писать много кода, обслуживающего тестирование. Однако при первом же проваливающемся тесте вы заметите, что отчёт о тестировании оставляет желать лучшего: если test-+ возвращает NIL , вы знаете, что какой-то тест провалился, но не имеете понятия, какой именно.

Давайте попробуем другой простой (можно даже сказать глупый) подход: чтобы проверить, что случилось с каждым тестом, будете писать так:

(defun test-+ ()

(format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2) 3) '(= (+ 1 2) 3))

(format t "~:[FAIL~;pass~] ... ~a~%" (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))

(format t "~:[FAIL~;pass~] ... ~a~%" (= (+ -1 -3) -4) '(= (+ -1 -3) -4)))

Теперь каждый тест будет сообщать результат отдельно. Часть ~:[FAIL~;pass~] форматной строки FORMAT печатает FAIL если первый аргумент ложен и pass если истинен [109] . Теперь запуск test-+ покажет подробности происходящего.

CL-USER> (test-+)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

pass ... (= (+ -1 -3) -4)

NIL

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

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

<p>Рефакторинг</p>

Что вам действительно нужно это способ писать тесты так элегантно, как в первой функции test-+ , которая возвращает T или NIL , но также отчитывается о результатах индивидуальных тестов, так, как во второй версии. Поскольку вторая версия близка по фукнциональности к тому, что вам нужно, лучшее, что вы можете сделать проверить, можно ли исключить из неё раздражающее дублирование.

Простейший способ избавиться от повторяющихся похожих вызовов FORMAT создать новую функцию.

(defun report-result (result form)

(format t "~:[FAIL~;pass~] ... ~a~%" result form))

Теперь вы можете писать test-+ , вызывая report-result вместо FORMAT . Не слишком упрощает жизнь, но по крайней мере если вы решите изменить вид выдаваемых результатов, то вам придётся менять код только в одном месте.

(defun test-+ ()

(report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))

(report-result (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))

(report-result (= (+ -1 -3) -4) '(= (+ -1 -3) -4)))

Следующее, что нужно сделать избавиться от дублирования тестового выражения, с присущим дублированию риском неправильной маркировки результата тестирования. Что вам нужно это возможность обработать тестовое выражение одновременно как код (для получения результата теста) и как данные (для использования в качестве метки теста). Использование кода, как данных это безошибочный признак того, что вам нужен макрос. Или, если посмотреть на это с другой стороны, вам нужен способ автоматизировать подверженное ошибкам написание вызовов report-result . Неплохо было бы написать что-то, похожее на

(check (= (+ 1 2) 3))

и чтобы это означало следующее:

(report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))

Написание макроса для выполнения этой трансформации тривиально.

(defmacro check (form)

`(report-result ,form ',form))

Теперь вы можете изменить test-+ , чтобы использовать check .

(defun test-+ ()

(check (= (+ 1 2) 3))

(check (= (+ 1 2 3) 6))

(check (= (+ -1 -3) -4)))

Раз уж вы устраняете дублирование, почему бы не избавиться от повторяющихся вызовов check ? Можно заставить check принимать произвольное количество аргументов и заворачивать каждый из них в вызов report-result .

(defmacro check (&body forms)

`(progn

,@(loop for f in forms collect `(report-result ,f ',f))))

Это определение использует общепринятую идиому оборачивание набора форм в вызов PROGN , чтобы сделать их единой формой. Заметьте, как можно использовать ,@ для FIXME вклеивания результата выражения, которое возвращает список выражений, которые сами по себе созданы с помощью шаблона, созданного обратной кавычкой.

С новой версией check можно написать новую версию test-+ следующим образом:

(defun test-+ ()

(check

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4)))

что эквивалентно следующему коду:

(defun test-+ ()

(progn

(report-result (= (+ 1 2) 3) '(= (+ 1 2) 3))

(report-result (= (+ 1 2 3) 6) '(= (+ 1 2 3) 6))

(report-result (= (+ -1 -3) -4) '(= (+ -1 -3) -4))))

Благодаря макросу check , этот вариант столь же краток, как первая версия test-+ , но раскрывается в код, который делает то же самое, что вторая версия. Кроме того, вы можете внести любые изменения в поведение test-+ , изменяя только check .

<p>Чиним возвращаемое значение</p>

Вы можете начать с изменения test-+ таким образом, чтобы его возвращаемое значение показывало, все ли тесты завершились успешно. Поскольку check отвечает за генерацию кода, который запускает тексты, вам нужно изменить его так, чтобы генерируемый код подсчитывал результаты тестов.

Для начала можно внести небольшое изменение в report-result , чтобы он возвращал результат выполняемого им теста.

(defun report-result (result form)

(format t "~:[FAIL~;pass~] ... ~a~%" result form)

result)

Теперь, когда report-result возвращает значение теста, кажется, что вы можете просто изменить PROGN на AND . К сожалению, AND не будет работать так, как вам хочется в этом случае, из-за своего FIXME short-circuit: как только один из тестов провалится, AND пропустит остальные. С другой стороны, если бы вы имели конструкцию, которая действует как AND , но не FIXME short-circuit, вы могли бы её использовать на месте PROGN . Common Lisp не предоставляет такой конструкции, но это не помешает вам использовать её: вы с легкостью написать макрос, предоставляющий такую конструкцию.

Оставляя тесты в стороне на минуту, вам нужен макрос (назовём его combine-results ), который позволит вам сказать

(combine-results

(foo)

(bar)

(baz))

и это будет значить

(let ((result t))

(unless (foo) (setf result nil))

(unless (bar) (setf result nil))

(unless (baz) (setf result nil))

result)

Единственный нетривиальный момент в написании этого макроса - это введение переменной ( result в предыдущем кусочке кода) в раскрытие макроса. Как вы видели в предыдущей главе, использование обычных имён для переменных в раскрытом макросе может заставить протекать абстракцию, так что вам нужно будет создать уникальное имя, что делается с помощью with-gensyms . Вы можете определить combine-results так:

(defmacro combine-results (&body forms)

(with-gensyms (result)

`(let ((,result t))

,@(loop for f in forms collect `(unless ,f (setf ,result nil)))

,result)))

Теперь вы можете исправить check , просто заменив PROGN на combine-results .

(defmacro check (&body forms)

`(combine-results

,@(loop for f in forms collect `(report-result ,f ',f))))

С этой версией check test-+ должен выдавать результаты своих трёх тестов и затем возвращать T , показывая, что все тесты завершились успешно [110].

CL-USER> (test-+)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

pass ... (= (+ -1 -3) -4)

T

Если вы измените один из тестов так, чтобы он проваливался [111], возвращаемое значение изменится на NIL .

CL-USER> (test-+)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

FAIL ... (= (+ -1 -3) -5)

NIL

<p>Улучшение отчёта</p>

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

(defun test-* ()

(check

(= (* 2 2) 4)

(= (* 3 5) 15)))

Теперь у вас есть две тестовые функции, так что вы возможно захотите написать ещё одну функцию, которая запускает все тесты. Это достаточно легко.

(defun test-arithmetic ()

(combine-results

(test-+)

(test-*)))

В этой функции вы используете combine-results вместо check , потому что и test-+ , и test-* сами позаботятся о выводе результатов своих тестов. Когда вы запустите test-arithmetic , вы получите следующий результат:

CL-USER> (test-arithmetic)

pass ... (= (+ 1 2) 3)

pass ... (= (+ 1 2 3) 6)

pass ... (= (+ -1 -3) -4)

pass ... (= (* 2 2) 4)

pass ... (= (* 3 5) 15)

T

Теперь представьте, что один из тестов провалился, и вам нужно найти проблему. Для пяти тестов и двух тестовых функций это будет не так сложно. Но представьте себе, что у вас 500 тестов, разнесённых по 20 функциям. Неплохо было бы, чтобы результаты сообщали вам, в какой функции находится каждый тест.

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

Это в точности та проблема, для решения которой были придуманы динамические переменные. Если вы создадите динамическую переменную, которая привязывается к имени тестовой функциии, то report-result сможет использовать её, а check может ничего о ней не знать.

Для начала определим переменную на верхнем уровне.

(defvar *test-name* nil)

Теперь слегка изменим report-result , чтобы включить *test-name* в вывод FORMAT .

(format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)

После этих изменений тестовые функции всё ещё работают, но выдают следующие результаты из-за того, что *test-name* нигде не привязывается к значению, отличному от начального:

CL-USER> (test-arithmetic)

pass ... NIL: (= (+ 1 2) 3)

pass ... NIL: (= (+ 1 2 3) 6)

pass ... NIL: (= (+ -1 -3) -4)

pass ... NIL: (= (* 2 2) 4)

pass ... NIL: (= (* 3 5) 15)

T

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

(defun test-+ ()

(let ((*test-name* 'test-+))

(check

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4))))

(defun test-* ()

(let ((*test-name* 'test-*))

(check

(= (* 2 2) 4)

(= (* 3 5) 15))))

Теперь результаты правильно помечены именами тестовых функций.

CL-USER> (test-arithmetic)

pass ... TEST-+: (= (+ 1 2) 3)

pass ... TEST-+: (= (+ 1 2 3) 6)

pass ... TEST-+: (= (+ -1 -3) -4)

pass ... TEST-*: (= (* 2 2) 4)

pass ... TEST-*: (= (* 3 5) 15)

T

<p>Выявление абстракций</p>

При внесении изменений в тестовые функции, вы снова получили дублирующийся код. Тестовые функции не только дважды включают своё имя первый раз при определении, второй раз при связывании с глобальной переменной *test-name* но обе они начинаются совершенно одинаково ( вся разница имя функции ). Вы могли бы попытаться избавиться от дублирования просто потому, что это некрасиво. Но если рассмотреть причину, вызвавшую дублирование, более подробно, то можно извлечь довольно важный урок по использованию макросов.

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

К сожалению, неполные абстракции плохие помошники при написании программ. Полуабстракция, описанная в коде соответствующим паттерном, гарантирует вам массовое дублирование кода со всеми сопутствующими проблемами поддержки этого кода в дальнейшем. Более того, так как подобные абстракции целиком существуют только в наших мыслях, у нас нет никакой возможности убедиться, что разные программисты ( или даже один и тот же но в разное время ) одинаково понимают одну и ту же абстракцию. Дабы полностью абстрагировать идею, вам нужно как-то выразить фразу "это тестовая функция" соответствующим паттерном. Другими словами, вам нужен макрос.

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

(defmacro deftest (name parameters &body body)

`(defun ,name ,parameters

(let ((*test-name* ',name))

,@body)))

Используя этот макрос, вы можете переписать test-+ следующим образом:

(deftest test-+ ()

(check

(= (+ 1 2) 3)

(= (+ 1 2 3) 6)

(= (+ -1 -3) -4)))

<p>Иерархия тестов</p>

Теперь, когда у вас есть полноценные тестовые функции, может возникнуть вопрос должна ли функция test-arithmetic также быть тестовой? Казалось бы если вы определите её с помощью deftest , то её связывание с *test-name* скроет связывания test-+ и test-* и это отразится на выводе результатов тестов.

Но представьте, что у вас есть тысяча ( или даже больше ) тестов, которые нужно как-то упорядочить. На самом нижнем уровне находятся такие функциями как test-+ и test-* , прямо выполняющих проверку. При наличии тысяч тестов их потребуется каким-либо образом упорядочить. Такие функции как test-arithmetic могут группировать схожие тестовые функции в наборы тестов. Допустим, что некоторые низкоуровневые тестовые функции могут использоваться в разных наборах тестов. Тогда вполне возможна такая ситуация, что тест будет пройден в одном контексте и провалится в другом. Если это случится, вам наверняка захочется узнать несколько больше, чем просто имя провалившегося теста.

Если вы определите test-arithmetic посредством deftest , сделав небольшие изменения при связывании с *test-name* , то сможете получить отчёты с более подробным описанием контекста выполнившегося теста:

pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)

Поскольку процесс определения тестовых функций описан отдельным паттерном, изменить отчёт можно и не меняя код самих тестовых функций [112]. Сделать так, чтобы *test-name* хранил список имён тестовых функций вместо имени последней вызванной фнкции, очень просто. Вам нужно всего лишь изменить связывание:

(let ((*test-name* ',name))

на такое:

(let ((*test-name* (append *test-name* (list ',name))))

Так как APPEND возвращает новый список, составленный из его аргументов, это версия будет связывать *test-name* со списком, содержащим старое значение *test-name* , с новым именем, добавленным в конец списка [113]. После выхода из функции, старое значение *test-name* восстанавливается.

Теперь вы можете переопределить test-arithmetic используя deftest вместо DEFUN .

(deftest test-arithmetic ()

(combine-results

(test-+)

(test-*)))

В результате вы получите именно то, что хотели:

CL-USER> (test-arithmetic)

pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)

pass ... (TEST-ARITHMETIC TEST-+): (= (+ 1 2 3) 6)

pass ... (TEST-ARITHMETIC TEST-+): (= (+ -1 -3) -4)

pass ... (TEST-ARITHMETIC TEST-*): (= (* 2 2) 4)

pass ... (TEST-ARITHMETIC TEST-*): (= (* 3 5) 15)

T

С ростом количества тестов, вы можете добавлять новые уровни и пока они будут определяться через deftest , вывод результата будт корректен. Так, если вы определите таким образом test-math :

(deftest test-math ()

(test-arithmetic))

то получите вот такой результат:

CL-USER> (test-math)

pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ 1 2) 3)

pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ 1 2 3) 6)

pass ... (TEST-MATH TEST-ARITHMETIC TEST-+): (= (+ -1 -3) -4)

pass ... (TEST-MATH TEST-ARITHMETIC TEST-*): (= (* 2 2) 4)

pass ... (TEST-MATH TEST-ARITHMETIC TEST-*): (= (* 3 5) 15)

T

<p>Подведение итогов</p>

Вы могли бы продолжить работу над этим каркасом, добавляя новые возможности но как каркас для написания тестов без особого напряжения и с возможностью использовать REPL , это очень неплохое начало. Ниже код приведён полностью, все 26 строк:

(defvar *test-name* nil)

(defmacro deftest (name parameters &body body)

"Define a test function. Within a test function we can call

other test functions or use 'check' to run individual test

cases."

`(defun ,name ,parameters

(let ((*test-name* (append *test-name* (list ',name))))

,@body)))

(defmacro check (&body forms)

"Run each expression in 'forms' as a test case."

`(combine-results

,@(loop for f in forms collect `(report-result ,f ',f))))

(defmacro combine-results (&body forms)

"Combine the results (as booleans) of evaluating 'forms' in order."

(with-gensyms (result)

`(let ((,result t))

,@(loop for f in forms collect `(unless ,f (setf ,result nil)))

,result)))

(defun report-result (result form)

"Report the results of a single test case. Called by 'check'."

(format t "~:[FAIL~;pass~] ... ~a: ~a~%" result *test-name* form)

result)

Этот пример прекрасно иллюстрирует обычный ход програмирования на языке Lisp , так что давайте рассмотрим процесс его написания ещё раз.

Вы начали с постановки задачи вычислить совокупность булевых выражений и узнать все ли они возвращают true . Простое AND работало и синтаксически было абсолютно верно но вывод результатов оставлял желать лучшего. Тогда вы написали немного дейстительно глуповатого кода, битком набитого повторениями и способствующими ошибкам выражениями, чтобы всё работало так, как вы хотели.

Естественно, что вы решили попробовать привести вторую версию программы к более ясному и красивому виду. Вы начали со стандартного приёма выделения части кода в отдельную функцию report-result . Увы, но использование report-result утомительно и чревато ошибками тестовое выражение приходится писать дважды. Тогда вы написали макрос check для автоматически корректного вызова report-result .

В процессе написания макроса check , вы добавили возможность оборачивать несколько вызовов report-result в один вызов check , сделав новую версию test-+ столь же краткой, как и первоначальную с AND .

Следующей задачей было исправить check таким образом, чтобы генерируемый этим макросом код возвращал t или nil в зависимости от того, все ли тесты прошли удачно. Прежде чем переиначивать check , вы предположили, что у вас есть FIXME non-short-circuit AND конструкция. В этом случае правка check тривиальное дело. Вы обнаружили, что хотя такой конструкции и нет, написать её самим совсем не трудно. После написания combine-results исправить check было элементарным делом.

Затем, всё, что оставалось, это сделать более удобным отчёт. Начав с исправления тестовых функций, вы представили их как особый вид функций и в результате написали макрос deftest , выделив паттерн, отличающий тестовые функции от всех прочих.

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

Теперь, имея представления об основах функциях, переменных и макросах; получив немного опыта по их практическому применению; вы готовы начать изучение богатой стандартной библиотеки функций и типов данных языка Common Lisp .

10
<p>10. Числа, знаки и строки</p>

В то время как функции, переменные, макросы и 25 специальных операторов составляют основные блоки самого языка, строительными блоками ваших программ будут структуры данных, которые вы будете использовать. Как заметил Фред Брукс (Fred Brooks) в своей книге "Мифический человеко-месяц (The Mythical Man-Month)", "представление это сущность программирования." [114]

Common Lisp предоставляет поддержку для основных типов, обычно существующих в современных языках программирования: чисел (целых, с плавающей запятой и комплексных чисел), знаков (characters) [115], строк, массивов (включая многомерные массивы), списков, хеш-таблиц (hash tables), потоков ввода-вывода, и переносимое представление имен файлов. В Lisp функции также являются типом данных они могут быть сохранены в переменных, переданы как аргументы, использованы в качестве возвращаемых значений, а также созданы во время выполнения.

Эти встроенные типы являются только началом. Они определены в стандарте языка, так что программисты могут рассчитывать на то, что они будут доступны, а также они могут быть реализованы более эффективно за счет более сильной интеграции с конкретной реализацией. Но, как вы увидите в следующих главах, Common Lisp также предоставляет вам возможности для определения новых типов данных, определения операций для них, и интеграции их со встроенными типами.

Однако сейчас вы начнете изучать встроенные типы данных. Поскольку Lisp является языком высокого уровня, детальная информация о реализации различных типов данных хорошо спрятана. С вашей точки зрения, как пользователя языка, встроенные типы данных определяются функциями, которые работают с ними. Так что, для изучения типа данных, вы просто должны изучить функции, которые используют этот тип. В дополнение к этому, большинство встроенных типов имеет специальный синтаксис, который понимает процедура чтения Lisp и который использует процедура печати Lisp. Поэтому, например, вы можете записывать строки как "foo" ; числа как 123 , 1/23 , и 1.23 ; а списки как (a b c) . Я буду описывать синтаксис для различных видов объектов тогда, когда я буду описывать функции для работы с этими типами данных.

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

<p>Числа</p>

Математика, как сказала Барби - очень тяжела. [116]Common Lisp не сделает математику более легкой, но он делает работу с ней более простой, чем многие другие языки программирования. В этом нет ничего удивительного, учитывая математическое наследство. Lisp был спроектирован математиками как средство для изучения математических функций. Одним из основных продуктов в составе проекта MAC университета MIT была система символьной алгебры Macsyma, написанная на Maclisp, одном из непосредственных предков Common Lisp. Кроме того, Lisp использовался как язык для обучения в таких заведениях как MIT, даже когда профессора компьютерных наук объясняли, что 10/4 = 5/2 , что привело к поддержке Lisp'ом целочисленных дробей (exact ratios). И много раз Lisp состязался с FORTRAN в области высокопроизводительных вычислений.

Одной из причин, почему Lisp является отличным языком для работы с математикой, является то, что поведение его чисел больше всего похоже на математическое, нежели та, жалкая пародия, которую легко реализовать на уровне оборудования. Например, целые числа в Common Lisp могут быть произвольной величины, а не ограничены размером машинного слова. [117] И деление двух целых чисел приводит к получению целочисленной дроби, а не усеченному значению. А поскольку дроби, представляются как пары произвольных чисел, они могут представлять числа с произвольной точностью. [118]

С другой стороны, для высокопроизводительных вычислений, вы можете захотеть пожертвовать точностью в погоне за скоростью, которая может быть достигнута за счет использования операций с плавающей точкой, поддерживаемых оборудованиям. Так что Common Lisp также предлагает несколько типов чисел с плавающей точкой, которые преобразуются реализацией в соответсвующие аппаратные представления, поддерживаемые конкретной платформой. [119]Числа с плавающей точкой, также используются для представления результатов вычислений, чье математическое значение может быть иррациональным числом.

Кроме того, Common Lisp имеет поддержку комплексных чисел чисел, которые получаются в результате таких операций, как вычисление квадратного корня и логарифмов отрицательных чисел. Стандарт Common Lisp имеет даже больше возможностей и позволяет указывать FIXME principal values and branch cuts для иррациональных и транденсцентальных функций для комплексных чисел.

<p>Запись чисел</p>

Вы можете записывать числа разными способами; вы видели это на примерах в главе 4. Однако, очень важно помнить о разнице между процедурой чтения Lisp и процедурой вычисления процедура чтения отвечает за преобразование текста в объекты Lisp, а процедура вычисления, работает только с этими объектами. Для конкретного числа с конкретным типом, может быть множество способов текстовой записи, все из которых преобразуются процедурой чтения в один и тот же объект. Например, вы можете записать целое число 10 как 10 , 20/2 , #xA , или еще дюжиной способов, но процедура чтения преобразует все эти числа в один и тот же объект. Когда числа выводятся на печать, например в FIXME REPL они печатаются в канонической текстовой форме, которая может отличаться от той формы, которая использовалась для задания числа. Например:

CL-USER> 10

10

CL-USER> 20/2

10

CL-USER> #xa

10

Целые числа записываются в следующем виде: необязательный знак числа ( + или - ), за которым следует одна или несколько цифр. Дроби записываются как необязательный знак числа, за ним последовательность цифр, представляющих числитель, знак косая черта ( / ), и другая последовательность цифр, представляющая знаменатель. Все рациональные числа "канонизируются" во время чтения вот поэтому 10 и 20/2 представляют одно и тоже число, также как и 3/4 и 6/8 . Рациональные числа печатаются в "упрощенной" форме целые числа печатаются с использованием синтаксиса для целых чисел, а дроби печатаются с числителем, и знаменателем, сокращенным до минимальных значений.

Также возможна запись числа с основанием, отличным от 10. Если число предваряется #B или #b , то число считывается как двоичное, в котором разрешены только цифры 0 и 1 . Строки #O или #o используются для восьмеричных чисел (допустимые цифры 0-7 ), а #X или #x используются для шестнадцетиричных (допустимые цифры 0-F или 0-f ). Вы можете записывать числа с использованием других оснований (от 2 до 36) с с указанием префикса #nR , где n определяет основание (всегда записывается в десятичном виде). В качестве дополнительных "цифр" используются буквы A-Z или a-z . Заметьте, что знак основания применяется ко всему числу невозможно написать дробь, где числитель использует одно основание исчисление, а знаменатель другое. Вы также можете записывать целые значения (но не дроби!) в виде десятичных цифр, завершаемых десятичной точкой. [120] Ниже приводятся некоторые примеры рациональных чисел, с их каноническим, десятичным представлением:

123 ==> 123

+123 ==> 123

-123 ==> -123

123. ==> 123

2/3 ==> 2/3

-2/3 ==> -2/3

4/6 ==> 2/3

6/3 ==> 2

#b10101 ==> 21

#b1010/1011 ==> 10/11

#o777 ==> 511

#xDADA ==> 56026

#36rABCDEFGHIJKLMNOPQRSTUVWXYZ ==> 8337503854730415241050377135811259267835

Числа с плавающей точкой вы также можете записывать разными способами. В отличии от рациональных чисел, синтаксис, используемый для записи может влиять на тип считываемого числа. Common Lisp имеет четыре подтипа для чисел с плавающей точкой: FIXME short, single, double и long. Каждый подтип может использовать разное количество бит для представления, что означает, что каждый тип может представлять значения разных диапазонов и с разной точностью. Большее количество бит дает более широкий диапазон и большую точность. [121]

Основным форматом для чисел с плавающей точкой является следующий: необязательный знак числа, за которым следует непустая последовательность десятичных цифр, возможно с указанием десятичной точки. За этой последовательностью может следовать маркер экспоненты для "компьютеризированной научной нотации." [122] Маркер экспоненты состоит из единственной буквы, за которой следует необязательный знак числа и последовательность цифр, которая интерпретируется как степень десяти на которую должно быть умножено число, указанное до маркера экспоненты. Буква в маркере экспоненты играет двойную роль: она обозначает начало маркера, и показывает что для числа должно использоваться представление с плавающей точкой. Маркеры экспоненты в виде букв s , f , d , l (и их заглавные эквиваленты) обозначают использование short, single, double и long подтипов. Буква e показывает, что должно использоваться представление по умолчанию (первоначально подтип single).

Числа без маркера экспоненты считываются с использованием представления по умолчанию и должны содержать десятичную точку, и как минимум одну цифру после нее, чтобы быть отличимы от целых чисел. Цифры в числах с плавающей запятой всегда рассматриваются как имеющие основание исчисления 10 синтаксис с префиксами #B , #X , #O , и #R могут использоваться только с рациональными числами. Ниже приведены примеры чисел с плавающей точкой, и их соответствующее каноническое представление:

1.0 ==> 1.0

1e0 ==> 1.0

1d0 ==> 1.0d0

123.0 ==> 123.0

123e0 ==> 123.0

0.123 ==> 0.123

.123 ==> 0.123

123e-3 ==> 0.123

123E-3 ==> 0.123

0.123e20 ==> 1.23e+19

123d23 ==> 1.23d+25

В заключение, комплексные числа имеют отличающийся синтаксис, а именно: префикс #C или #c , за которым следует список из двух чисел, представляющих реальную и мнимую часть комплексного числа. В действительности существует пять видов комплексных чисел, поскольку реальная и мнимая части могут либо быть рациональными числами или числами с плавающей точкой.

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

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

#c(2 1) ==> #c(2 1)

#c(2/3 3/4) ==> #c(2/3 3/4)

#c(2 1.0) ==> #c(2.0 1.0)

#c(2.0 1.0d0) ==> #c(2.0d0 1.0d0)

#c(1/2 1.0) ==> #c(0.5 1.0)

#c(3 0) ==> 3

#c(3.0 0.0) ==> #c(3.0 0.0)

#c(1/2 0) ==> 1/2

#c(-6/3 0) ==> -2

<p>Базовые математические операции</p>

Базовые математические операции сложение, вычитание, умножение и деление, реализуются всеми типами чисел Lisp с помощью функций functions + , - , * и / . Вызов любой из этих функций с количеством аргументов больше двух, эквивалентен вызову той же самой функции для первых двух аргументов, и последующим вызовом функции для результата и оставшихся аргументов. Например, (+ 1 2 3) эквивалентно (+ (+ 1 2) 3) . При вызове с одним аргументом, + и * возвращают само значение; - возвращает отрицание значения, а / возвращает значение обратное аргументу. [123]

(+ 1 2) ==> 3

(+ 1 2 3) ==> 6

(+ 10.0 3.0) ==> 13.0

(+ #c(1 2) #c(3 4)) ==> #c(4 6)

(- 5 4) ==> 1

(- 2) ==> -2

(- 10 3 5) ==> 2

(* 2 3) ==> 6

(* 2 3 4) ==> 24

(/ 10 5) ==> 2

(/ 10 5 2) ==> 1

(/ 2 3) ==> 2/3

(/ 4) ==> 1/4

Если все аргументы имеют один тип (рациональный, с плавающей точкой или комплексный), то тип результата будет тем же, за исключением случая, когда операция с комплексными приводит к получению результата с нулевой мнимой частью, и тогда результат будет иметь рациональный тип. Однако комплексные числа и числа с плавающей точкой являются "заразными" если все аргументы являются рациональными числами, а одно или больше чисел являются числами с плавающей точкой, то все остальные аргументы преобразуются в соответствующие числа с плавающей точкой с типом, соответствующим "наибольшему" типу аргументов с плавающей точкой. Числа с плавающей точкой с "меньшим" представлением, также преобразуются в тип с "большим" представлением. То же самое происходит и с комплексными числами все обычные числа преобразуются в комплексные числа.

(+ 1 2.0) ==> 3.0

(/ 2 3.0) ==> 0.6666667

(+ #c(1 2) 3) ==> #c(4 2)

(+ #c(1 2) 3/2) ==> #c(5/2 2)

(+ #c(1 1) #c(2 -1)) ==> 3

Поскольку / при делении не отбрасывает остаток, то Common Lisp предлагает четыре вида функций для усечения и округления для вещественных чисел (рациональных или с плавающей точкой) в целые числа: FLOOR усекает число в сторону отрицательной бесконечности, возвращая наибольшее целое, меньшее, или равное аргументу. CEILING усекает число в сторону положительной бесконечности, возвращая наименьшее целое большее или равное аргументу. TRUNCATE усекает число в сторону нуля, ведя себя как FLOOR для положительных аргументов, и как CEILING для отрицательных. А ROUND округляет число до ближайшего целого. Если аргумент находится ровно между двумя целыми числами, то округление происходит в сторону ближайшего четного числа.

К этой же теме можно отнести и две функции MOD и REM , которые возвращают модуль и остаток деления с усечением чисел. Эти две функции соотносятся с FLOOR и TRUNCATE следующими отношениями:

(+ (* (floor (/ x y)) y) (mod x y)) === x

(+ (* (truncate (/ x y)) y) (rem x y)) === x

Таким образом, для положительных частных они будут эквивалентны, но для отрицательных, они будут давать разные результаты. [124])

Функции 1+ и 1- могут использоваться как сокращения для добавления и вычитания единицы из числа. Заметьте, что они отличаются от макросов INCF и DECF . 1+ и 1- являются функциями, которые возвращают значения, а INCF и DECF изменяют заданное значение. Следующие примеры показывают соответствие между INCF / DECF , 1+ / 1- и + / - :

(incf x) === (setf x (1+ x)) === (setf x (+ x 1))

(decf x) === (setf x (1- x)) === (setf x (- x 1))

(incf x 10) === (setf x (+ x 10))

(decf x 10) === (setf x (- x 10))

<p>Сравнение чисел</p>

Функция = является предикатом для проверки равенства чисел. Она сравнивает значения математически, игнорируя разницу в типах. Таким образом, = будет считать равными математически равные значения разных типов, в то время как общий предикат равенства EQL будет считать их не равными, из-за разницы типов. (При этом общий предикат равенства EQUALP использует = для сравнения чисел.) Если эта функция была вызвана с более чем одним аргументом, то она вернет истинное значение только если они все имеют одно и тоже значение. Так что:

(= 1 1) ==> T

(= 10 20/2) ==> T

(= 1 1.0 #c(1.0 0.0) #c(1 0)) ==> T

В противоположность этому, функция /= , вернет истинное значение, если все ее аргументы имеют разное значение.

(/= 1 1) ==> NIL

(/= 1 2) ==> T

(/= 1 2 3) ==> T

(/= 1 2 3 1) ==> NIL

(/= 1 2 3 1.0) ==> NIL

Функции < , > , <= и >= сравнивают порядок рациональных чисел и чисел с плавающей точкой (другими словами, вещественных чисел). Подобно = и /= , эти функции могут быть вызваны с более чем двумя аргументами, и в этом случае каждый аргумент сравнивается с аргументом, находящимся справа от него.

(< 2 3) ==> T

(> 2 3) ==> NIL

(> 3 2) ==> T

(< 2 3 4) ==> T

(< 2 3 3) ==> NIL

(<= 2 3 3) ==> T

(<= 2 3 3 4) ==> T

(<= 2 3 4 3) ==> NIL

Для выбора наименьшего или наибольшего числа из нескольких, вы можете использовать функцию MIN или MAX , которые принимают любое число вещественных аргументов, и возвращают минимальное или максимальное значение.

(max 10 11) ==> 11

(min -12 -10) ==> -12

(max -1 2 -3) ==> 2

Другими полезными функциями являются ZEROP , MINUSP и PLUSP , которые проверяют, является ли одиночное вещественное число равным, меньшим или большим чем ноль. Два других предиката, EVENP и ODDP , проверяют является ли число четным или нечетным. Суффикс P в именах этих функций соответствует стандарту наименования предикатных функций, которые проверяют некоторое условие и возвращают логическое значение.

<p>Высшая математика</p>

Функции которые вы уже увидели являются началом списка встроенных математических функций. Lisp также поддерживает логарифмы: LOG ; экспонеты: EXP и EXPT ; основные тригонометрические функции: SIN , COS и TAN ; их противоположности: ASIN , ACOS и ATAN ; гиперболические функции: SINH , COSH и TANH ; и их противоположности: ASINH , ACOSH и ATANH . Также имеются функции для доступа к отдельным битам целых чисел и для извлечения частей комплексных чисел. Для получения полного списка функций смотрите в любой справочник по Common Lisp.

<p>Знаки (Characters)</p>

В Common Lisp знаки (characters) являются отдельным типом объектов, а не числами. Это то, что и должно быть знаки не являются числами, и языки, которые рассматривают их как числа, столкнуться с проблемами, когда изменится кодировка знаков, например, с 8-битного ASCII на 21-битный Unicode. [125] Поскольку стандарт Common Lisp не требует конкретного представления для знаков, некоторые из существующих реализаций Lisp используют Unicode как "родную" кодировку знаков, несмотря на то, что Unicode только задумывался в то время, когда проводилась стандартизация Common Lisp.

Синтаксис чтения для объектов-знаков очень простой: префикс #\ за которым следует нужный знак. Так что, #\x обозначает знак x . После #\ может использоваться любой знак, включая специальные знаки, такие как " , ( и пробел. Однако, поскольку запись пробелов и аналогичных знаков не особенно хорошо выглядит (для человека), то для некоторых знаков существует альтернативный синтаксис, состоящий из #\ , за которым следует название знака. Список поддерживаемых имен зависит от набора знаков и реализации Lisp, но все реализации поддерживают имена Space и Newline . Так что вы должны писать #\Space вместо #\ , хотя последний вариант и допустим с технической точки зрения. Другими полу-стандартными именами (которые реализации должны использовать, если набор знаков содержит соответствующие знаки) являются Tab , Page , Rubout , Linefeed , Return и Backspace .

<p>Сравнение знаков</p>

Основной операцией которую вы можете выполнить со знаками, отличной от помещения их в строки (что будет описано дальше в этой главе), является их сравнение с другими знаками. Поскольку знаки не являются числами, то вы не можете использовать такие функции как < и > . Вместо этого два набора функций обеспечивают операции над знаками, аналогичные операциям сравнения чисел; одни функции учитывают регистр знаков, а другие нет.

Чувствительным к регистру аналогом численной функции = является функция CHAR= . Также как и = , CHAR= может получать любое количество аргументов, и возвращает истину, если они все являются одним и тем же знаком. Нечувствительная к регистру функция называется CHAR-EQUAL .

Остальные функции сравнения знаков, следуют той же схеме наименования: чувствительные к регистру функции именуются путем добавления CHAR к имени аналогичной функции для сравнения чисел; а нечувствительные к регистру функции добавляют к CHAR название операции, отделенное знаком минус. Однако заметьте, что <= и >= "именуются" логическими эквивалентами операций NOT-GREATERP и NOT-LESSP , а не более подробными названиями вида LESSP-OR-EQUALP и GREATERP-OR-EQUALP . Также как и их численные аналоги, все эти функции могут принимать один или больше аргументов. Таблица 10-1 показывает отношение между функциями сравнения для чисел и знаков.

Таблица 10-1. Функции сравнения знаков

Numeric Analog Case-Sensitive Case-Insensitive ^ = CHAR= CHAR-EQUAL | /= CHAR/= CHAR-NOT-EQUAL | < CHAR< CHAR-LESSP | > CHAR> CHAR-GREATERP | <= CHAR<= CHAR-NOT-GREATERP | >= CHAR>= CHAR-NOT-LESSP |

Другие функции работающие со знаками, среди прочего предоставляют операции по проверке является ли знак буквой или цифрой, проверке регистра знака, получению соответствующего знака в другом регистре, а также по преобразованию между численным значением и соответствующим знаком, и наоборот. Для получения полной информации об этих функциях, смотрите справочник по Common Lisp.

<p>Строки</p>

Как упоминалось ранее, строки в Common Lisp на самом деле являются составными типами данных, а именно одномерными массивами букв. Соответственно, я опишу много вещей, которые можно сделать со строками, когда в следующей главе я буду описывать функции для работы с последовательностями, подтипом которых и являются строки. Но строки также имеют свой собственный синтаксис и библиотеку функций для выполнения операций, специфичных для строк. Я буду обсуждать данные особенности в этой главе, а все остальное будет обсуждаться в главе 11.

Как вы уже видели, строки записываются заключенными в двойные кавычки. Вы можете включить в строку любой знак, поддерживаемую набором знаков, за исключением двойной кавычки ( " ) и обратного слэша ( \ ). Вы можете включить и эти два знака, если вы замаскируете их с помощью обратного слэша. В действительности, знак обратный слэш всегда маскирует следующий знак, независимо от того, чем он является, хотя нет необходимости использовать его для чего-то отличного от " и самого себя. Таблица Table 10-2 показывает как различные записи строк будут считываться процедурой чтения Lisp.

Таблица 10-2. Представление строк

Literal Contents Comment ^ "foobar" foobar Обычная строка. | "foo\"bar" foo"bar Обратный слэш маскирует кавычку. | "foo bar" foo\bar Первый обратный слэш маскирует второй. | "\"foobar\"" "foobar" Знаки обратные слэш маскируют кавычки. | "foo\bar" foobar Обратный слэш "маскирует" знак b |

Заметьте, что FIXME REPL обычно печатает строки в форме, пригодной для считывания, добавляя охватывающие знаки кавычек, и нужные маскирующие знаки. Так что если вы хотите видеть содержимое строки, то вы должны использовать какую-либо функцию, такую как FORMAT , спроектированную для печати данных в форме, удобной для человека. Например, вот что вы увидите, если напечатаете строку, содержащую знак кавычки в REPL:

CL-USER> "foo\"bar"

"foo\"bar"

С другой стороны, FORMAT выведет содержимое строки: [126]

CL-USER> (format t "foo\"bar")

foo"bar

NIL

<p>Сравнение строк</p>

Вы можете сравнивать строки используя набор функций, который использует то же самое соглашение о наименовании, что и функции для сравнения знаков, с той разницей, что они используют в качестве префикса слово STRING вместо CHAR (смотрите таблицу 10-3).

Table 10-3. Функции сравнения строк

Numeric Analog Case-Sensitive Case-Insensitive ^ = STRING= STRING-EQUAL | /= STRING/= STRING-NOT-EQUAL | < STRING< STRING-LESSP | > STRING> STRING-GREATERP | <= STRING<= STRING-NOT-GREATERP | >= STRING>= STRING-NOT-LESSP |

Однако в отличии от сравнения знаков и чисел, функции сравнения строк могут сравнивать только две строки. Это происходит потому, что эти функции также получают именованные аргументы, которые позволяют вам ограничить области сравнения одной или обоих строк. Аргументы: :start1 , :end1 , :start2 и :end2 указывают начальный (включительно) и конечный (исключительно FIXME exclusive) индексы подстрок в первой и второй строке. Таким образом, следующий код:

(string= "foobarbaz" "quuxbarfoo" :start1 3 :end1 6 :start2 4 :end2 7)

сравнивает подстроки "bar" в двух аргументах, и возвращает истинное значение. Аргументы :end1 и :end2 могут иметь значение NIL (или именованные аргументы могут быть полностью убраны) для указания, что соответствующие подстроки берутся до конца строк.

Функции сравнения, которые возвращают истинное значение, когда их аргументы отличаются друг от друга это все функции за исключением STRING= и STRING-EQUAL , возвращают индекс позиции в первой строке, где строки начинают отличаться.

(string/= "lisp" "lissome") ==> 3

Если первая строка является префиксом второй строки, то возвращаемое значение будет равно длине первой строки, что на единицу больше чем самый большой допустимый индекс в этой строке.

(string< "lisp" "lisper") ==> 4

При сравнении подстрок, результатом все равно будет являться индекс в полной строке. Например, следующий код сравнивает подстроки "bar" и "baz" , но возвращает 5 , поскольку это индекс знака r в первой строке:

(string< "foobar" "abaz" :start1 3 :start2 1) ==> 5 ; N.B. not 2

Другие строковые функции позволяют вам преобразовывать регистр знаков в строке, а также удалять (trim) знаки с одного, или обоих концов строки. И, как я уже отмечал перед этим, поскольку строки на самом деле являются последовательностями, все функции работы с последовательностями, которые я буду описывать в следующей главе, могут быть использованы для работы со строками. Например, вы можете узнать длину строки с помощью функции LENGTH и можете получить отдельный знак строки с помощью функции доступа к элементу последовательности ELT , или функции доступа к элементу массива AREF . Или вы можете использовать функцию доступа к элементу строки CHAR . Но эти функции являются предметом обсуждения следующей главы, так что перейдем к ней.

11
<p>Коллекции</p>

Подобно большинству языков программирования, Common Lisp предоставляет стандартные типы данных, которые собирают множество значений в один объект. Каждый язык решает проблему коллекций немного по разному, но базовые типы коллекций обычно сводятся к наличию массивов с целочисленными индексами, и таблицам, которые могут использоваться для отображения произвольных (более или менее) ключей в значения. Первые часто называются массивами, списками или кортежами (записями, FIXME tuple), а вторые хэш-таблицами, ассоциативными массивами, картами и словарями.

Конечно, Lisp известен своими списками, поэтому, согласно принципу "ontogeny-recapitulates-phylogeny", большинство учебников по Lisp начинают объяснение коллекций со списков.

Здесь следует перевести "Онтогенез повторяет филогенез" Это правило было сформулировано немецким зоологом Эрнстом Геккелем. Сказав это, он имел в виду, что развитие зародыша (онтогенез) повторяет эволюцию видов (филогенез). [127]

Автор по отношению к обучению Lisp очевидно имел в виду то, что так как списки были первой основной структурой данных Lisp и лишь затем стали добавляться другие, то большинство книг описывают структуры данных Lisp в такой же последовательности, то есть сначала списки, а потом другие структуры данных. (dream.designer)

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

Чтобы списки не заслоняли картину, в этой главе я сосредоточусь на остальных типах коллекций, используемых в Common Lisp: векторах и хэш-таблицах. [128]Однако векторы и списки имеют достаточно много общих признаков, так что Common Lisp рассматривает их как подтипы более общей абстракции последовательности. Таким образом, многие функции, описанные в этой главе, можно использовать как для векторов, так и для списков.

<p>Векторы</p>

Векторы Common Lisp являются базовой коллекцией с доступом по целочисленому индексу, и имеет две разновидности. Векторы с фиксированным размером похожи на массивы в языках, подобных Java: простая надстройка над областью памяти, которая хранит элементы вектора. [129] С другой стороны, векторы с изменяемым размером, более похожи на векторы в Perl или Ruby, списки в Python, или на класс ArrayList в Java: они прячут детали реализации хранилища данных, позволяя векторам менять размер по мере добавления или удаления элементов.

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

(vector) ==> #()

(vector 1) ==> #(1)

(vector 1 2) ==> #(1 2)

Синтаксис #(...) является способом записи векторов, используемый процедурами записи и чтения Lisp. Это позволяет вам сохранять и восстанавливать векторы путем их вывода на печать и последующего считывания. Вы можете использовать #(...) для записи векторов в вашем коде, но поскольку эффект изменения таких объектов не определен, то вы всегда должны использовать VECTOR , или более общую функцию MAKE-ARRAY для создания векторов, которые вы планируете изменять.

MAKE-ARRAY является более общей функцией, чем VECTOR , поскольку вы можете использовать ее как для создания массивов любой размерности, так и для создания векторов фиксированной и изменяемой длины. Единственным обязательным аргументом MAKE-ARRAY является список, содержащий размерности массива. Поскольку, вектор одномерный массив, то список будет содержать только одно число размер вектора. Для удобства, MAKE-ARRAY может также принимать простое число вместо списка из одного элемента. Без предоставления дополнительных аргументов, MAKE-ARRAY создаст вектор с неициализированными элементами, которые должны быть заданы до осуществления доступа к ним. [130] Для создания вектора, с присвоением всем элементам определенного значения, вы можете использовать аргумент :initial-element . Таким образом, чтобы создать вектор из пяти элементов, которые все равны NIL , вы должны написать следующее:

(make-array 5 :initial-element nil) ==> #(NIL NIL NIL NIL NIL)

MAKE-ARRAY может также использоваться для создания векторов переменного размера. Вектор с изменяемым размером, является более сложным, чем вектор фиксированного размера; в добавление к отслеживанию памяти, используемой для хранения элементов и количества доступных ячеек, вектор с изменяемым размером также отслеживает число элементов, сохраненных в векторе. Это число хранится в указателе заполнения вектора (vector's fill pointer), так названного, поскольку это индекс следующей позиции, которая будет заполнена, когда вы добавите элемент в вектор.

Чтобы создать вектор с указателем заполнения, вы должны передать MAKE-ARRAY аргумент :fill-pointer . Например, следующий вызов MAKE-ARRAY создаст вектор с местом для пяти элементов; но он будет выглядеть пустым, поскольку указатель заполнения равен нулю:

(make-array 5 :fill-pointer 0) ==> #()

Для того, чтобы добавить элемент в конец вектора, вы можете использовать функцию VECTOR-PUSH . Она добавляет элемент в точку, указываемую указателем заполнения, и затем увеличивает его на единицу, возвращая индекс ячейки, куда был добавлен новый элемент. Функция VECTOR-POP возвращает последний добавленный элемент, уменьшая указатель заполнения на единицу.

(defparameter *x* (make-array 5 :fill-pointer 0))

(vector-push 'a *x*) ==> 0

*x* ==> #(A)

(vector-push 'b *x*) ==> 1

*x* ==> #(A B)

(vector-push 'c *x*) ==> 2

*x* ==> #(A B C)

(vector-pop *x*) ==> C

*x* ==> #(A B)

(vector-pop *x*) ==> B

*x* ==> #(A)

(vector-pop *x*) ==> A

*x* ==> #()

Однако, даже вектор с указателем заполнения, не является настоящим вектором с изменяемыми размерами. Вектор *x* может хранить максимум пять элементов. Для того, чтобы создать вектор с изменяемым размером, вам необходимо передать MAKE-ARRAY другой именованный аргумент: :adjustable .

(make-array 5 :fill-pointer 0 :adjustable t) ==> #()

Этот вызов приведет к созданию вектора, чей размер может изменяться по мере необходимости. Для добавления элементов в такой вектор, вам нужно использовать функцию VECTOR-PUSH-EXTEND , которая работает также как и VECTOR-PUSH , за тем исключением, что она автоматически увеличит массив, если вы пытаетесь добавить элемент в уже заполненный вектор вектор, чей указатель заполнения равен размеру выделенной памяти. [131]

<p>Подтипы векторов</p>

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

С одним из них мы уже встречались: строки это векторы, предназначенные для хранения знаков. Строки достаточно важны, чтобы они имели собственный синтаксис чтения/записи (двойные кавычки) и набор отдельных функций, которые мы обсуждали в предыдущей главе. Но, поскольку они также являются векторами, все функции работающие с векторами и которые мы будем обсуждать в следующих разделах, могут также использоваться для работы со строками. Эти функции дополнят библиотеку функций работы со строками новыми функциями для таких вещей, как поиск подстроки в строке, нахождение позиции знака в строке и т.п.

Строки, такие как "foo" , подобны векторам, записанным с использованием синтаксиса #() их размер фиксирован, и они не должны изменяться. Однако вы можете использовать функцию MAKE-ARRAY для создания строк с изменяемым размером, просто добавляя еще один именованный аргумент :element-type . Этот аргумент принимает описание типа. Я не буду тут описывать типы, которые вы можете использовать; сейчас достаточно знать, что вы можете создать строку, путем передачи символа CHARACTER в качестве аргумента :element-type . Заметьте, что вам необходимо экранировать символ, чтобы он не считался именем переменной. Например, чтобы создать пустую строку, с изменяемым размером, вы можете написать вот так:

(make-array 5 :fill-pointer 0 :adjustable t :element-type 'character) ""

Битовые векторы (специализированные векторы, чьи элементы могут иметь значение ноль или один) также отличаются от обычных векторов. Они также имеют специальный синтаксис чтения/записи, который выглядит вот так #*00001111 , а также, достаточно большой набор функций (которые я не буду тут описывать) для выполнения битовых операций, таких как выполнение "и" для двух битовых массивов. Для создания такого вектора, вам нужно передать :element-type символ BIT .

<p>Векторы как последовательности</p>

Как уже упоминалось ранее, векторы и списки являются подтипами абстрактного типа "последовательность". Все функции, которые будут обсуждаться в следующих разделах, работают с последовательностями; в добавление к тому, что они применимы к векторам (и специализированным, и общего назначения), они также могут использоваться и со списками.

Двумя простейшими функциями для работы с последовательностями являются LENGTH , которая возвращает длину последовательности, и ELT , которая осуществляет доступ к отдельным элементам, используя целочисленный индекс. LENGTH получает последовательность в качестве единственного аргумента и возвращает число элементов в этой последовательности. Для векторов с указателем заполнения, это число будет равно значению указателя. ELT (сокращение слова элемент), получает два аргумента последовательность и числовой индекс между нулем (включительно) и длиной последовательности (n-1) и возвращает соответствующий элемент. ELT выдаст ошибку, если индекс находится за границами последовательности. Подобно LENGTH , ELT рассматривает вектор с указателем заполнения, как имеющий длину, указанную этим указателем.

(defparameter *x* (vector 1 2 3))

(length *x*) ==> 3

(elt *x* 0) ==> 1

(elt *x* 1) ==> 2

(elt *x* 2) ==> 3

(elt *x* 3) ==> error

ELT возвращает ячейку, для которой можно выполнить SETF , так что вы можете установить значение отдельного элемента с помощью вот такого кода:

(setf (elt *x* 0) 10)

*x* ==> #(10 2 3)

<p>Функции для работы с элементами последовательностями</p>

Хотя в теории, все операции над последовательностями могут быть сведены к комбинациям LENGTH , ELT , и SETF на результат ELT , но Common Lisp все равно предоставляет большую библиотеку функций для работы с последовательностями.

Одна группа функций позволит вам выполнить некоторые операции, такие как нахождение или удаление определенных элементов, без явного написания циклов. Краткая сводка этих функций приводится в таблице 11-1.

Table 11-1. Базовые функции для работы с последовательностями

Название Обязательные аргументы Возвращаемое значение ^ COUNT Объект и последовательность Число вхождений в последовательности | FIND Объект и последовательность Объект или NIL | POSITION Объект и последовательность Индекс ячейки в последовательности или NIL | REMOVE Удаляемый объект и последовательность Последовательность, из которой удалены указанные объекты | SUBSTITUTE Новый объект, заменяемый объект и последовательность Последовательность, в которой указанные объекты заменены на новые |

Вот несколько простых примеров использования этих функций:

(count 1 #(1 2 1 2 3 1 2 3 4)) ==> 3

(remove 1 #(1 2 1 2 3 1 2 3 4)) ==> #(2 2 3 2 3 4)

(remove 1 '(1 2 1 2 3 1 2 3 4)) ==> (2 2 3 2 3 4)

(remove #\a "foobarbaz") ==> "foobrbz"

(substitute 10 1 #(1 2 1 2 3 1 2 3 4)) ==> #(10 2 10 2 3 10 2 3 4)

(substitute 10 1 '(1 2 1 2 3 1 2 3 4)) ==> (10 2 10 2 3 10 2 3 4)

(substitute #\x #\b "foobarbaz") ==> "fooxarxaz"

(find 1 #(1 2 1 2 3 1 2 3 4)) ==> 1

(find 10 #(1 2 1 2 3 1 2 3 4)) ==> NIL

(position 1 #(1 2 1 2 3 1 2 3 4)) ==> 0

Заметьте, что REMOVE и SUBSTITUTE всегда возвращают последовательность того-же типа, что и переданный аргумент.

Вы можете изменить поведение этих функций используя различные именованные аргументы. Например, по умолчанию, эти функции ищут в последовательности точно такой же объект, что и переданный в качестве аргумента. Вы можете изменить это поведение двумя способами: во первых, вы можете использовать именованный аргумент :test для указания функции, которая принимает два аргумента, и возвращает логическое значение. Если этот аргумент указан, то он будет использоваться для сравнения каждого элемента, вместо стандартной проверки на равенство с помощью EQL . [132]Во вторых, используя именованный параметр :key вы можете передать функцию одного аргумента, которая будет вызвана для каждого элемента последовательности для извлечения значения, которое затем будет сравниваться с переданным объектом. Однако заметьте, что функции (например FIND ), возвращающие элементы последовательности, все равно будут возвращать элементы, а не значения, извлеченные из этих элементов.

(count "foo" #("foo" "bar" "baz") :test #'string=) ==> 1

(find 'c #((a 10) (b 20) (c 30) (d 40)) :key #'first) ==> (C 30)

Для ограничения действия этих функций в рамках только определенных пределов, вы можете указать граничные индексы, используя именованные аргументы :start и :end . Передача NIL в качестве значения :end (или его полное отсутствие) равносильно указанию длины последовательности. [133]

Если указывается неравный NIL аргумент :from-end , то элементы последовательности проверяются в обратном порядке. Простое указание :from-end может затронуть результаты FIND и POSITION . Например:

(find 'a #((a 10) (b 20) (a 30) (b 40)) :key #'first) ==> (A 10)

(find 'a #((a 10) (b 20) (a 30) (b 40)) :key #'first :from-end t) ==> (A 30)

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

(remove #\a "foobarbaz" :count 1) ==> "foobrbaz"

(remove #\a "foobarbaz" :count 1 :from-end t) ==> "foobarbz"

И хотя :from-end не может изменить результат функции COUNT , его использование может влиять на порядок элементов, передаваемых функциям, указанным в параметрах :test и :key , которые возможно могут использовать FIXME побочные эффекты (side effects). Например:

CL-USER> (defparameter *v* #((a 10) (b 20) (a 30) (b 40)))

*V*

CL-USER> (defun verbose-first (x) (format t "Looking at ~s~%" x) (first x))

VERBOSE-FIRST

CL-USER> (count 'a *v* :key #'verbose-first)

Looking at (A 10)

Looking at (B 20)

Looking at (A 30)

Looking at (B 40)

2

CL-USER> (count 'a *v* :key #'verbose-first :from-end t)

Looking at (B 40)

Looking at (A 30)

Looking at (B 20)

Looking at (A 10)

2

В таблице 11-2 приведены описания всех стандартных аргументов.

Table 11-2. Стандартные именованные аргументы функций работы с последовательностями

Аргумент Описание Значение по умолчанию ^ :test Функция двух аргументов, используемая для сравнения элементов (или значений, извлеченных функцией :key ) с указанным объектом. EQL | :key Функция одного аргумента, используемая для извлечения значения из элемента последовательности. NIL указывает на использование самого элемента. NIL | :start Начальный индекс (включительно) обрабатываемой последовательности. 0 | :end Конечный индекс (n-1) обрабатываемой последовательности. NIL указывает на конец последовательности. NIL | :from-end Если имеет истинное значение, то последовательность будет обрабатываться в обратном порядке, от конца к началу. NIL | :count Число, указывающее количество удаляемых или заменяемых элементов, или NIL для всех элементов (только для REMOVE и SUBSTITUTE ). NIL |
<p>Аналогичные функции высшего порядка</p>

Для каждой из функций, которая была описана выше, Common Lisp также предоставляет два набора функций высшего порядка, которые вместо аргумента, используемого для сравнения, получают функцию, которая вызывается для каждого элемента последовательности. Первый набор функций имеет те же имена, что и функции из базового набора, но с добавлением суффикса -IF . Эти функции подсчитывают, ищут, удаляют и заменяют элементы, для которых аргумент-функция возвращает истинное значение. Другой набор функций, отличается тем, что использует суффикс -IF-NOT , и выполняет те же операции, но для элементов, для которых функция не возвращает истинного значения.

(count-if #'evenp #(1 2 3 4 5)) ==> 2

(count-if-not #'evenp #(1 2 3 4 5)) ==> 3

(position-if #'digit-char-p "abcd0001") ==> 4

(remove-if-not #'(lambda (x) (char= (elt x 0) #\f))

#("foo" "bar" "baz" "foom")) ==> #("foo" "foom")

В соответствии со стандартом языка, функции с суффиксом -IF-NOT являются устаревшими. Однако, это требование само считается неразумным. Если стандарт будет пересматриваться, то скорее будет удалено это требование, а не функции с суффиксом -IF-NOT . Для некоторых вещей, REMOVE-IF-NOT может использоваться чаще, чем REMOVE-IF . За исключением своего отрицательно звучащего имени, в действительности REMOVE-IF-NOT является положительной функцией она возвращает элементы, которые соответствуют предикату. [134]

Оба варианта функций принимают те же именованные аргументы, что и базовые функции, за исключением аргумента :test , которые не нужен, поскольку главный аргумент сам является функцией. [135] При указании аргумента :key , функции передается значение, извлеченное функцией аргумента :key , а не сам элемент.

(count-if #'evenp #((1 a) (2 b) (3 c) (4 d) (5 e)) :key #'first) ==> 2

(count-if-not #'evenp #((1 a) (2 b) (3 c) (4 d) (5 e)) :key #'first) ==> 3

(remove-if-not #'alpha-char-p

#("foo" "bar" "1baz") :key #'(lambda (x) (elt x 0))) ==> #("foo" "bar")

Семейство функций REMOVE также имеет четвертый вариант, функцию REMOVE-DUPLICATES , которая имеет один аргумент последовательность, из которой удаляются все, кроме одного экземпляра, каждого дублированного элемента. Она может принимать те же самые именованные аргументы что и REMOVE , за исключением :count , поскольку она всегда удаляет все дубликаты.

(remove-duplicates #(1 2 1 2 3 1 2 3 4)) ==> #(1 2 3 4)

<p>Работа с последовательностью целиком</p>

Некоторое количество функций выполняют операции над последовательностями целиком. Это приводит к тому, что они проще чем функции, которые я описывал ранее. Например, COPY-SEQ и REVERSE получают по одному аргументу последовательности, и возвращают новую последовательность того же самого типа. Последовательность, возвращенная COPY-SEQ содержит те же самые элементы, что и последовательность, аргумент этой функции, в то время как последовательность возвращенная REVERSE содержит те же самые элементы, но в обратном порядке. Заметьте, что ни одна из этих функций не копирует сами элементы, новым объектом является только возвращаемая последовательность.

Функция CONCATENATE создает новую последовательность, содержащую объединение любого числа последовательностей. Однако, в отличии от REVERSE и COPY-SEQ , которые просто возвращают последовательность того же типа, что и переданный аргумент, функции CONCATENATE должно быть явно указано, какой тип последовательности необходимо создать в том случае, если ее аргументы имеют разные типы. Первым аргументом функции является описание типа, подобный параметру :element-type функции MAKE-ARRAY . В этом случае, вероятнее всего вы будет использовать следующие символы для указания типа: VECTOR , LIST или STRING . [136]Например:

(concatenate 'vector #(1 2 3) '(4 5 6)) ==> #(1 2 3 4 5 6)

(concatenate 'list #(1 2 3) '(4 5 6)) ==> (1 2 3 4 5 6)

(concatenate 'string "abc" '(#\d #\e #\f)) ==> "abcdef"

<p>Сортировка и слияние</p>

Функции SORT и STABLE-SORT обеспечивают два метода сортировки последовательности. Они обе получают последовательность и функцию двух аргументов, и возвращают отсортированную последовательность.

(sort (vector "foo" "bar" "baz") #'string<) ==> #("bar" "baz" "foo")

Разница между этими функциями заключается в том, что STABLE-SORT гарантирует, что она не будет изменять порядок элементов, которые считаются одинаковыми, в то время как SORT гарантирует только, что результат будет отсортирован, так что некоторые одинаковые элементы могут быть поменяны местами.

Обе эти функции являются примерами так называемых деструктивных функций. Деструктивным функциям допускается (обычно в целях эффективности) модификация переданных аргументов тем или иным способом. Отсюда следует две вещи: во первых, вы всегда должны что-то делать с возвращаемым значением этих функций (например присвоить его переменной, или передать его другой функции), и во вторых, если вы хотите что-то еще делать с передаваемым в деструктивную функцию аргументом, то вы должны передать его копию, а не оригинальное значение. Я расскажу о деструктивных функциях более подробно в следующей главе.

Обычно вы не беспокоитесь о несортированной версии последовательности, так что имеет смысл позволить SORT и STABLE-SORT менять последовательность в процессе ее сортировки. Но это значит, что вы должны запомнить, что вы должны писать: [137]

(setf my-sequence (sort my-sequence #'string<))

вместо:

(sort my-sequence #'string<)

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

Функция MERGE принимает две последовательности и функцию-предикат, и возвращает последовательность, полученную путем слияния двух последовательностей в соответствии с предикатом. Она относится к функциям сортировки, так что, если каждая последовательность уже была отсортирована с использованием того же самого предиката, то и полученная последовательность также будет отсортирована. Также как и функции сортировки, MERGE принимает аргумент :key . Подобно CONCATENATE , и по тем же причинам, первым аргументом MERGE должно быть описание типа последовательности, которая будет получена в результате работы.

(merge 'vector #(1 3 5) #(2 4 6) #'<) ==> #(1 2 3 4 5 6)

(merge 'list #(1 3 5) #(2 4 6) #'<) ==> (1 2 3 4 5 6)

<p>Работа с частями последовательностей</p>

Другой набор функций позволит вам работать с частями последовательностей. Наиболее часто употребляемой функцией является SUBSEQ , которая выделяет часть последовательности начиная с определенного индекса и заканчивая другим индексом или концом последовательности. Например:

(subseq "foobarbaz" 3) ==> "barbaz"

(subseq "foobarbaz" 3 6) ==> "bar"

Для результата SUBSEQ также можно выполнить SETF , но оно не сможет увеличить или уменьшить последовательность; если часть последовательности и новое значение имеют разные длины, то более короткое из них определяет то, как много ???знаков будет изменено.

(defparameter *x* (copy-seq "foobarbaz"))

(setf (subseq *x* 3 6) "xxx") ; subsequence and new value are same length

*x* ==> "fooxxxbaz"

(setf (subseq *x* 3 6) "abcd") ; new value too long, extra character ignored.

*x* ==> "fooabcbaz"

(setf (subseq *x* 3 6) "xx") ; new value too short, only two characters changed

*x* ==> "fooxxcbaz"

Вы можете использовать функцию FILL для заполнения нескольких значений последовательность одним и тем же значением. Обязательными аргументами являются последовательность и значение, которым нужно заполнить элементы. По умолчанию, заполняется вся последовательность; вы можете использовать именованные аргументы :start и :end для ограничения границ заполнения.

Если вам нужно найти одну последовательность внутри другой, то вы можете использовать функцию SEARCH , которая работает также как и функция POSITION , но первым аргументом является последовательность, а не единичное значение.

(position #\b "foobarbaz") ==> 3

(search "bar" "foobarbaz") ==> 3

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

(mismatch "foobarbaz" "foom") ==> 3

Эта функция возвращает NIL если строки совпадают. MISMATCH может также принимать стандартные именованные аргументы: аргумент :key для указания функции для извлечения сравниваемых значений; аргумент :test для указания функции сравнения; и аргументы :start1 , :end1 , :start2 и : end2 для указания границ действия внутри последовательностей. Также, указание :from-end со значением T приводит к тому, что поиск осуществляется в обратном порядке, заставляя MISMATCH вернуть индекс позиции в первой последовательности, где начинается общий суффикс последовательностей.

(mismatch "foobar" "bar" :from-end t) ==> 3

<p>Предикаты для последовательностей</p>

Другими полезными функциями являются EVERY , SOME , NOTANY и NOTEVERY , которые пробегают по элементам последовательности выполняя заданный предикат. Первым аргументом всех этих функций является предикат, а остальные аргументы последовательности. Предикат должен получать столько аргументов, сколько последовательностей будет передано функциям. Элементы последовательностей передаются предикату (по одному элементу за раз) пока не закончатся элементы, или не будет выполнено условие завершения: EVERY завершается, возвращая ложное значение, сразу как это значение будет возвращено предикатом. Если предикат всегда вовзращает истинное значение, то функция также вернет истинное значение. SOME возвращает первое не NIL значение, возвращенное предикатом, или возвращает ложное значение, если предикат никогда не вернул истинного значения. NOTANY возвращает ложное значение, если предикат возвращает истинное значение, или ложное, если этого не произошло. А NOTEVERY возвращает истинное значение сразу, как только предикат возвращает ложное значение, или ложное, если предикат всегда возвращал истинное. Вот примеры проверок для одной последовательности:

(every #'evenp #(1 2 3 4 5)) ==> NIL

(some #'evenp #(1 2 3 4 5)) ==> T

(notany #'evenp #(1 2 3 4 5)) ==> NIL

(notevery #'evenp #(1 2 3 4 5)) ==> T

В эти вызовы выполняют попарное сравнение последовательностей:

(every #'> #(1 2 3 4) #(5 4 3 2)) ==> NIL

(some #'> #(1 2 3 4) #(5 4 3 2)) ==> T

(notany #'> #(1 2 3 4) #(5 4 3 2)) ==> NIL

(notevery #'> #(1 2 3 4) #(5 4 3 2)) ==> T

<p>Функции отображения последовательностей</p>

В заключение, последними из функций работы с последовательностями, будут рассмотрены функции отображения (mapping). Функция MAP , подобно функциям-предикатам для последовательностей, получает функцию нескольких аргументов, и несколько последовательностей. Но вместо логического значения, MAP возвращает новую последовательность, содержащую результаты применения функции к элементам последовательности. Также как для CONCATENATE и MERGE , MAP необходимо сообщить тип создаваемой последовательности.

(map 'vector #'* #(1 2 3 4 5) #(10 9 8 7 6)) ==> #(10 18 24 28 30)

Функция MAP-INTO похожа на MAP за исключением того, что вместо создания новой последовательности заданного типа, она помещает результаты в последовательность, заданную в качестве первого аргумента. Эта последовательность может иметь такой же тип, как одна из последовательность, предоставляющих данные для функции. Например, для суммирования нескольких вектров a , b и c в один, вы должны написать:

(map-into a #'+ a b c)

Если последовательности имеют разную длину, то MAP-INTO изменяет столько элементов, сколько присутствует в самой короткой последовательности, включая ту, в которую помещаются результаты. Однако, если последовательность будет отображаться в вектор с указателем заполнения, то число изменяемых элементов будет определяться не указателем заполнения, а размером вектора. После вызова MAP-INTO , вектор заполнения будет установлен равным количеству измененных элементов. Однако MAP-INTO не будет изменять размер векторов, которые допускают такую операцию.

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

(reduce #'+ #(1 2 3 4 5 6 7 8 9 10)) ==> 55

REDUCE является очень полезной функцией когда вам нужно создать из последовательности одно значение, есть вероятность, что вы сможете сделать это с помощью REDUCE , и она часто приводит к лаконичной записи того, что вы хотите сделать. Например, для нахождения максимального значения в последовательности вы можете просто написать (reduce #'max numbers) . REDUCE также принимает полный набор стандартных именованных аргументов ( :key , :from-end , :start и :end ), а также один, уникальный для REDUCE ( :initial-value ). Этот аргумент указывает значение, которое будет логически помещено до первого элемента последовательности (или после последнего, если вы также зададите :from-end истинное значение).

<p>Хэш-таблицы</p>

Другой коллекцией общего назначения предоставляемой Common Lisp является хэш-таблица. В то время как вектора являются структурами данных с доступом по целочисленному индексу, хэш-таблицы позволяют вам использовать в качестве индексов (ключей) любые объекты. Когда вы добавляете объекты в хэш-таблицу, вы сохраняете их с определенным ключом. Позднее вы можете использовать тот же самый ключ для доступа к значению. Или вы можете связать новое значение с тем же самым ключом каждый ключ отображается в единственное значение.

Без указания дополнительных аргументов MAKE-HASH-TABLE создает хэш-таблицу, которая сравнивает ключи с использованием функции EQL . Это нормально до тех пор, пока вы не захотите использовать в качестве ключей строки, поскольку две строки с одинаковым содержимым, не обязательно равны в терминах EQL . В таком случае вы захотите использовать для сравнения функцию EQUAL , что вы можете сделать передав функции MAKE-HASH-TABLE символ EQUAL в качестве именованного аргумента :test . Кроме того, для аргумента :test можно использовать еще два символа: EQ и EQUALP . Конечно, эти символы являются именами стандартных функций сравнения объектов, которые я обсуждал в главе 4. Однако в отличии от аргумента :test , передаваемого функциям работы с последовательностями, аргумент :test функции MAKE-HASH-TABLE не может использовать произвольную функцию допустимы только значения EQ , EQL , EQUAL и EQUALP . Это происходит потому что хэш-таблицы в действительности нуждаются в двух функциях функции сравнения и функции хэширования, которая вычисляет численный хэш-код из ключа способом, совместимым с тем как функция сравнения однозначно сравнивает два ключа. Хотя стандарт языка предоставляет хэш-таблицы, которые используют только стандартные функции сравнения, большинство реализаций обеспечивают некоторые механизмы для создания более тонко настраиваемых хэш-таблиц.

Функция GETHASH обеспечивает доступ к элементам хэш-таблиц. Она принимает два аргумента: ключ и хэш-таблицу, и возвращает значение, если оно найдено, или NIL в противном случае. [138] Например:

(defparameter *h* (make-hash-table))

(gethash 'foo *h*) ==> NIL

(setf (gethash 'foo *h*) 'quux)

(gethash 'foo *h*) ==> QUUX

Поскольку GETHASH возвращает NIL если ключ не присутствует в таблице, то нет никакого способа узнать отсутствует ли ключ в таблице, или для данного ключа соответствует значение NIL . Функция GETHASH решает эту проблему за счет использования способа, который мы еще не обсуждали возврат нескольких значений. В действительности GETHASH возвращает два значения: главное значение значение для указанного ключа или NIL . Дополнительное значение имеет логический тип и указывает, присутствует ли значение в хэш-таблице. Из-за способа реализации возврата множественных значений, дополнительные значения просто отбрасываются, если только пользователь не обрабатывает эту ситуацию специальным образом, используя средства, которые "видят" множественные значения.

Я буду подробно обсуждать возврат множественных значений в главе 20, но сейчас я дам вам лишь общее представление о том, как использовать макрос MULTIPLE-VALUE-BIND для получения дополнительных значений, которые возвращает GETHASH . Макрос MULTIPLE-VALUE-BIND создает привязки переменных, как это делает LET , заполняя их множеством значений, возвращенных вызываемой функцией.

Следующие примеры показывают как вы можете использовать MULTIPLE-VALUE-BIND ; связываемые переменные содержат значение и признак его наличия в таблице:

(defun show-value (key hash-table)

(multiple-value-bind (value present) (gethash key hash-table)

(if present

(format nil "Значение ~a присутствует в таблице." value)

(format nil "Значение равно ~a, поскольку ключ не найден." value))))

(setf (gethash 'bar *h*) nil) ; создает ключ со значением NIL

(show-value 'foo *h*) ==> "Значение QUUX присутствует в таблице."

(show-value 'bar *h*) ==> "Значение NIL присутствует в таблице."

(show-value 'baz *h*) ==> "Значение равно NIL , поскольку ключ не найден."

Поскольку установка значения в NIL оставляет ключ в таблице, вам понадобится другая функция для полного удаления пары ключ/значение. Функция REMHASH получает такие же аргументы как и GETHASH , и удаляет указанную запись. Вы также можете полностью очистить хэш-таблицу с помощью функции CLRHASH .

<p>Функции для работы с записями в хэш-таблицах</p>

Common Lisp предоставляет разные способы для работы с записями в хэш-таблицах. Простейшим из них является использование функции MAPHASH . Также как и функция MAP , функция MAPHASH принимает в качестве аргументов функцию двух аргументов и хэш-таблицу, и выполняет указанную функцию для каждой пары ключ/значение. Например, для вывода всех пар ключ/значение, вы можете использовать такой вызов MAPHASH :

(maphash #'(lambda (k v) (format t "~a => ~a~%" k v)) *h*)

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

(maphash #'(lambda (k v) (when (< v 10) (remhash k *h*))) *h*)

Другим способом итерации по элементам хэш-таблицы является использование макроса LOOP , который будет описан в главе 22. [139] Код использующий LOOP , реализующий то же, что и предыдущий пример, будет выглядеть вот так:

(loop for k being the hash-keys in *h* using (hash-value v)

do (format t "~a => ~a~%" k v))

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

12
<p>12. Они назвали его Lisp неспроста: обработка списков</p>

Списки в языке Lisp играют важную роль как по историческим, так и по практическим причинам. Изначально списки были основным составным типом данных в языке Lisp, и в течении десятилетий они были единственным составным типом данных. В наши дни, программист на Common Lisp может использовать как типы vector, hash table, самостоятельно определённые типы и структуры, так и списки.

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

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

<p>Списков нет</p>

Мальчик с ложкой: Не пытайся согнуть список. Это невозможно. Вместо этого... попытайся понять истину.

Нео: Какую истину?

Мальчик с ложкой: Списков нет.

Нео: Списков нет?

Мальчик с ложкой: И тогда ты поймёшь: это не списки, которые сгибаются; это только ты сам. [140]

Ключом к пониманию списков, является осознание того, что они, по большей части, иллюзия, построенная на основе объектов более примитивных типов данных. Эти простые объекты - пары значений, называемые cons-ячейкой, от имени функции CONS , используемой для их создания.

CONS принимает 2 аргумента и возвращает новую cons-ячейку, содержащую 2 значения [141]. Эти значения могут быть ссылками на объект любого типа. Если второе значение не NIL и не другая cons-ячейка, то ячейка печатается как два значения в скобках, разделённые точкой (так называемая точечная пара).

(cons 1 2) ==> (1 . 2)

Два значения в cons-ячейке называются CAR и CDR - по имени функций, используемых для доступа к ним. На заре времён эти имена были мнемониками, по крайней мере для людей, работающих над первой реализацией Lisp на IBM 704. Но даже тогда они были всего лишь позаимствованы из мнемоник ассемблера, используемых для реализации этих операций. Тем не менее, это не так уж плохо, что названия этих функций несколько бессмысленны, применительно к конкретной cons-ячейке лучше думать о них просто как о произвольной паре значений без какого-либо особого смысла. Итак:

(car (cons 1 2)) ==> 1

(cdr (cons 1 2)) ==> 2

Оба CAR и CDR могут меняться функцией SETF : если дана cons-ячейка, то возможно присвоить значение обеим её составляющим. [142]

(defparameter *cons* (cons 1 2))

*cons* ==> (1 . 2)

(setf (car *cons*) 10) ==> 10

*cons* ==> (10 . 2)

(setf (cdr *cons*) 20) ==> 20

*cons* ==> (10 . 20)

Т.к. значения в cons-ячейке могут быть ссылками на любой объект, вы можете строить большие структуры, связывая cons-ячейки между собой. Списки строятся связыванием cons-ячеек в цепочку. Элементы списка содержатся в CAR cons-ячеек, а связи со следующими cons-ячейками содержатся в их CDR . Последняя ячейка в цепочке имеет CDR со значением NIL , которое - как я говорил в pcl:синтаксисисемантика|главе 4 . - представляет собой как пустой список, так и булево значение ложь.

Такая компоновка отнюдь не уникальна для Lisp, она называется однонаправленный список. Несколько языков, не входящих в семейство Lisp, предоставляют расширенную поддержку для этого скромного типа данных.

Таким образом, когда я говорю, что какое-то значение является списком, я имею в виду, что оно либо NIL , либо ссылка на cons-ячейку. CAR cons-ячейки является первым элементом списка, а CDR является ссылкой на другой список, который в свою очередь также является cons-ячейкой, содержащий остальные элементы, или значением NIL . Lisp-печаталка понимает это соглашение и печатает такие цепочки cons-ячеек как списки в скобках, нежели как точечные пары.

(cons 1 nil) ==> (1)

(cons 1 (cons 2 nil)) ==> (1 2)

(cons 1 (cons 2 (cons 3 nil))) ==> (1 2 3)

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

one-cons-cell.png

Блок слева представляет CAR , а блок справа - CDR . Значения, хранимые в ячейках также представлены в виде отдельных блоков или в виде стрелки выходящей из блока, для представления ссылки на значение. [143]Например, список (1 2 3) , который состоит из трёх cons-ячеек, связанных вместе с помощью их CDR , может быть изображён так:

list-1-2-3.png

Однако, в основном при работе со списками вы не обязаны иметь дело с отдельными ячейками, т.к. существуют функции, которые создают списки и используются для манипулирования ими. Например, функция LIST строит cons-ячейки и связывает их вместе, следующие LISP -выражения эквивалентны CONS -выражениям, приведённым выше:

(list 1) ==> (1)

(list 1 2) ==> (1 2)

(list 1 2 3) ==> (1 2 3)

Аналогично, когда вы думаете в терминах списков, вы не должны использовать бессмысленные имена CAR и CDR ; вы должны использовать FIRST и REST - синонимы для CAR и CDR при рассмотрении cons-ячеек, как списков.

(defparameter *list* (list 1 2 3 4))

(first *list*) ==> 1

(rest *list*) ==> (2 3 4)

(first (rest *list*)) ==> 2

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

(list "foo" (list 1 2) 10) ==> ("foo" (1 2) 10)

Структура списка при этом выглядит так:

mixed-list.png

Т.к. элементами списка могут быть другие списки, то с помощью списков можно представлять деревья произвольной глубины и сложности. Таким образом, они идеально подходят для представления гетерогенных иерархических данных. Например, XML-процессоры, основанные на Lisp, как правило представляют XML-документы в виде списков. Другим очевидным примером данных с древовидной структурой является сам код на языке Lisp. В главах 30 и 31 вы напишете библиотеку для генерации HTML-кода, которая использует списки списков для представления кода, который необходимо сгенерировать. В следующей главе я расскажу больше об использовании cons-ячеек для представления других типов данных.

Common Lisp предоставляет обширную библиотеку функций для работы со списками. В разделах #Функции для работы со списками|Функции для работы со списками и #Отображение|Отображение вы узнаете о наиболее важных из них. Данные функции будет проще понять в контексте идей, взятых из парадигмы функционального программирования.

<p>Функциональное программирование и списки</p>

Суть функционального программирования в том, что программы состоят исключительно из функций без побочных эффектов, которые вычисляют свои значения исключительно на основе своих аргументов. Преимущество функционального стиля программирования в том, что он облегчает понимание программы. Устранение побочных эффектов ведёт к устранению практически всех возможностей удалённого воздействия. Т.к. результат функции определяется её аргументами, поведение функции легче понять и протестировать. Например, когда вы видите выражение (+ 3 4) , вы знаете, что результат целиком задаётся определение функции + и переданными аргументами 3 и 4. Вам не надо беспокоиться о том, что могло произойти в программе до этого, т.к. нет ничего, что могло бы изменить результат вычисления выражения.

Функции, работающие с числами, естественным образом являются функциональными, т.к. числа неизменяемы. Списки же, как вы видели раннее, могут меняться, при применении функции SETF над ячейками списка. Тем не менее, списки могут рассматриваться как функциональные типы данных, если считать, что их значение определяется элементами, которые они содержат. Так, любой список вида (1 2 3 4) функционально эквивалентен любому другому списку, содержащему эти четыре значения, независимо от того, какие cons-ячейки используются для представления списка. И любая функция, которая принимает списки в качестве аргументов и возвращает значение, основываясь исключительно на содержании списка, могут считаться функциональными. Например, если функции REVERSE передать список (1 2 3 4) , она всегда вернёт (4 3 2 1) . Различные вызовы REVERSE с функционально-эквивалентными аргументами вернёт функционально-эквивалентные результаты. Другой аспект функционального программирования, который я рассматриваю в разделе "Отображение", это использование функций высших порядков: функций, которые используют другие функции, как данные, принимая их в качестве аргументов или возвращая в качестве результата.

Большинство функций Common Lisp для работы со списками написаны в функциональном стиле. Позднее мы обсудим как совмещать функциональный подход к программированию с другими подходами, но сначала вы должны понять некоторые тонкости функционального подхода применительно к спискам.

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

(append (list 1 2) (list 3 4)) ==> (1 2 3 4)

С точки зрения функционального подхода, задача функции APPEND - вернуть список (1 2 3 4) не изменяя ни одну из cons-ячеек в списках-аргументах (1 2) и (3 4) . Очевидно, что это можно сделать создав новый список, состоящий из четырёх новых cons-ячеек. Однако в этом есть лишняя работа. Вместо этого APPEND на самом деле создаёт только две новые cons-ячейки, чтобы хранить значений 1 и 2, соединяя их вместе и делая ссылку из CDR второй ячейки на первый элемент последнего аргумента - списка (3 4) . После этого функция возвращает cons-ячейку содержащую 1. Ни одна из входных cons-ячеек не была изменена, и результатом, как и требовалось, является список (1 2 3 4) . Единственная хитрость в том, что результат, возвращаемый функцией APPEND имеет общие cons-ячейки со списком (3 4) . Структура результата выглядит так:

after-append.png

В общем случае, функция APPEND должна копировать все кроме последнего аргумента-списка, и она может вернуть результат, которые имеет общие структуры с последним аргументом.

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

<p>"Разрушающие" операции</p>

Если бы Common Lisp был строго функциональным языком, больше не о чем было бы говорить. Однако, т.к. после создания cons-ячейки есть возможность изменить её значение применив функцию SETF над её CAR и CDR , мы должны обсудить как стыкуются побочные эффекты и общие структуры.

Из-за функционального наследия языка Lisp, операции, которые изменяют существующие объекты называются разрушающими - в функциональном программировании изменение состояния объекта "разрушает" его, т.к. объект больше не представляет того же значения, что до применения функции. Однако использование одного и того же термина для обозначения всего класса операций, изменяющих состояние существующих объектов, ведёт к некоторой путанице, т.к. существует 2 сильно отличающихся класса таких операций: операции-для-побочных-эффектов и утилизирующие операции. [144]

Операции-для-побочных-эффектов это те, которые используются ради их эффектов. В этом смысле, всякое использование SETF является разрушающим, как и использование функций, которые вызывают SETF чтобы изменить состояние объектов, например VECTOR-PUSH или VECTOR-POP . Но это несколько некорректно объявлять операции разрушающими, т.к. они не предназначены для написания программ в функциональном стиле, т.е. они не могут быть описаны с использованием терминов функциональной парадигмы. Тем не менее, если вы смешиваете нефункциональные операции-для-побочных-эффектов с функциями возвращающими результаты с общими структурами, то надо быть внимательным, чтобы случайно не изменить эти общие структуры. Например, имеем:

(defparameter *list-1* (list 1 2))

(defparameter *list-2* (list 3 4))

(defparameter *list-3* (append *list-1* *list-2*))

После вычисления у вас 3 списка, но list-3 и list-2 имеют общую структуру, как показано на предыдущей диаграмме.

*list-1* ==> (1 2)

*list-2* ==> (3 4)

*list-3* ==> (1 2 3 4)

Посмотрим, что случится если мы изменим list-2 :

(setf (first *list-2*) 0) ==> 0

*list-2* ==> (0 4) ; как и ожидалось

*list-3* ==> (1 2 0 4) ; а вот этого возможно вы и не хотели

Из-за наличия общих структур, изменения в списке list-2 привели к изменению списка list-3 : первая cons-ячейка в list-2 является также третьей ячейкой в list-3 . Изменение значения FIRST списка list-2 изменили значение CAR в cons-ячейке, изменив оба списка.

Совсем по-другому обстоит дело с утилизирующими операциями, которые предназначены для запуска в функциональном коде. Они используют побочные эффекты лишь для оптимизации. В частности, они повторно используют некоторые cons-ячейки своих аргументов для получения результатов. Но в отличии от таких функций, как APPEND , которые используют cons-ячейки, включая их без изменений в результирующий список, утилизирующие операции используют cons-ячейки как сырьё, изменяя их CAR и CDR для получения желаемого результата. Таким образом утилизирующие операции спокойно могут быть использованы, когда их аргументы не пригодятся после их выполнения.

Чтобы понять как работают функции утилизации сравним неразрушающую операцию REVERSE , которая возвращает инвертированный аргумент, с функцией NREVERSE , которая является утилизирующей функцией и делает то же самое. Т.к. REVERSE не меняет своих аргументов, она создаёт новую cons-ячейку для каждого элемента списка-аргумента. Но предположим, вы напишите:

(setf *list* (reverse *list*))

Присвоив результат вычисления переменной *list* , вы удалили ссылку на начальное значение *list* . Если на cons-ячейки в начальном списке больше никто не ссылается, то они теперь доступны для сборки мусора. Тем не менее в большинстве реализаций Lisp более эффективным является повторно использовать эти ячейки, чем создавать новые, а старые превращать в мусор.

NREVERSE именно это и делает. N - сокращение для "не создающая", в том смысле, что она не создаёт новых cons-ячеек. Конкретные побочные эффекты NREVERSE явно не описываются, она может проводить любые модификации над CAR и CDR любых cons-ячеек аргумента, но типичная реализация может быть такой: проход по списку с изменением CDR каждой cons-ячейки, так чтобы она указывала на предыдущую cons-ячейку, в конечном счёте результатом будет cons-ячейка, которая была последней в списке аргументе, а теперь является головой этого списка. Никакие новые cons-ячейки при этом не создаются и сборка мусора не производится.

Большинство утилизирующих функций, таких как NREVERSE , имеют своих неразрушающих двойников, которые вычисляют тот же результат. В общем случае утилизирующие функции имеют такое же имя, как их недеструктивные двойники с подставленной первой буквой N. Но есть и исключения, например часто используемые: NCONC - утилизирующая версия APPEND и DELETE , DELETE-IF , DELETE-IF-NOT , DELETE-DUPLICATED - версии семейства функций REMOVE .

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

Однако ещё большую неразбериху вносит небольшой набор утилизирующих функций со строго определёнными побочными эффектами, на которые можно положиться. В этот набор входят NCONC , утилизирующая версия APPEND , и NSUBSTITUTE и её -IF и -IF-NOT варианты - утилизирующие версии группы функций SUBSTITUTE .

Как и APPEND , NCONC возвращает соединение своих аргументов, но строит такой результат следующим образом: для каждого непустого аргумента-списка, NCONC устанавливает в CDR его последней cons-ячейки ссылку на первую cons-ячейку следующего непустого аргумента-списка. После этого она возвращает первый список, который теперь является головой результата-соединения. Таким образом:

(defparameter *x* (list 1 2 3))

(nconc *x* (list 4 5 6)) ==> (1 2 3 4 5 6)

*x* ==> (1 2 3 4 5 6)

На функцию NSUBSTITUTE и её варианты можно положиться в следующем её поведении: она пробегает по списковой структуре аргумента-списка и устанавливает с помощью функции SETF новое значения в CAR его cons-ячеек. После этого она возвращает переданный ей аргумент-список, который теперь имеет то же значение, как если бы был вычислен с помощью SUBSTITUTE . [145]

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

<p>Комбинирование утилизации с общими структурами</p>

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

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

На практике утилизирующие функции используются в нескольких определённых случаях. Наиболее частый случай - построение списка, который будет возвращён из функции добавлением элементов в его конец (как правило с использованием функции PUSH ), а потом инвертирование данного списка. При этом список храниться в локальной для данной функции переменной. [146]

Это является эффективным способом построения списка, потому что каждый вызов PUSH создаёт только одну cons-ячейку, а вызов NREVERSE быстро пробегает по списку, переназначая CDR ячеек. Т.к. список создаётся в локальной переменной внутри функции, то нет никакой возможности, что какой-то код за пределами функции имеет общие структуры с данным списком. Вот пример функции, которая использует такой приём для построения списка, который содержит числа от 0 до n [147]:

(defun upto (max)

(let ((result nil))

(dotimes (i max)

(push i result))

(nreverse result)))

(upto 10) ==> (0 1 2 3 4 5 6 7 8 9)

Другой часто встречающийся случай применения утилизирующих функций [148] - немедленное переприсваивание значения, возвращаемого утилизирующей функцией переменной, содержащей потенциально утилизированное значение. Например, вы часто будете такие видеть выражения с использованием функций DELETE (утилизирующей версии REMOVE ):

(setf foo (delete nil foo))

Эта конструкция присваивает переменной foo её старое значение с удалёнными элементами, равными NIL . Однако, здесь утилизирующие функции должны применяться осмотрительно: если foo имеет общие структуры с другими списками, то использование DELETE вместо REMOVE может разрушить структуры этих других списков. Например, возьмём два списка list-2 и list-3 , рассмотренные раннее, они разделяют свои последние 2 cons-ячейки.

*list-2* ==> (0 4)

*list-3* ==> (1 2 0 4)

Вы можете удалить 4 из list-3 так:

(setf *list-3* (delete 4 *list-3*)) ==> (1 2 0)

Но DELETE скорее всего произведёт удаление тем, что установит CDR третьей ячейки списка в NIL , отсоединив таким образом четвёртую ячейку от списка. Т.к. третья ячейка в list-3 является также первой ячейкой в list-2 , этот код изменить также и list-2 :

*list-2* ==> (0)

Если вы используете REMOVE вместо DELETE , то результат будет построен с помощью создания новых ячеек, не изменяя ячеек list-3 . В этом случае list-2 не будет изменён.

Эти два случая использования вместе составляют около 80% от общего числа случаев использования утилизирующих функций. Другие случаи использования возможны, но требуют внимательного отслеживания того возвращают ли функции списки с общими структурами или нет.

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

Важный момент, на который следует обратить внимание: функции сортировки списков SORT , STABLE-SORT и MERGE из главы 11 также являются утилизирующими функциями когда они применяются к спискам [149]. Эти функции не имеют неразрушающих аналогов, так что если вам необходимо отсортировать списки не разрушая их, вы должны передать в сортирующую функцию копию списка, сделанную с помощью COPY-LIST . В любом случае, вы должны сохранить результат функции, т.к. исходный аргумент-список будет разрушен. Например:

CL-USER> (defparameter *list* (list 4 3 2 1))

*LIST*

CL-USER> (sort *list* #'<)

(1 2 3 4) ; кажется то, что надо

CL-USER> *list*

(4) ; упс!

<p>Функции для работы со списками</p>

Теперь вы готовы взглянуть на библиотеку функций для работы со списками, которую предоставляет Common Lisp.

Вы уже видели базовые функции для извлечения элементов списка: FIRST и REST . Хотя вы можете получить любой элемент списка комбинируя вызовы REST (для продвижения по списку) и FIRST (для выделения элемента), это может быть утомительным занятием. Поэтому Lisp предоставляет функции от SECOND до TENTH извлекающие соответствующие элементы списка. Более общая функция NTH принимает два аргумента: индекс и список, и возвращает n-ый (начиная с нуля) элемент списка. Также существует функция NTHCDR , принимающая индекс и список и возвращающая результат n-кратного применения REST к списку. Таким образом, (nthcdr 0 ...) просто возвращает исходный список, а (nthcdr 1 ...) эквивалентно вызову REST . Имейте в виду, что ни одна из этих функций не является более производительной по сравнению с эквивалентной комбинацией FIRST и REST , т.к. нет иного способа получить n-ый элемент списка без n-кратного вызова CDR [150].

28 составных CAR/CDR функций составляют ещё одно семейство, которое вы можете время от времени использовать. Имя каждой функции получается подстановкой до 4 букв A или D между C и R, каждая A представляет вызов CAR , а каждая D - CDR . Таким образом:

(caar list) === (car (car list))

(cadr list) === (car (cdr list))

(cadadr list) === (car (cdr (car (cdr list))))

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

(caar (list 1 2 3)) ==> ошибка

(caar (list (list 1 2) 3)) ==> 1

(cadr (list (list 1 2) (list 3 4))) ==> (3 4)

(caadr (list (list 1 2) (list 3 4))) ==> 3

В настоящее время, эти функции не используются так часто, как раньше. Даже упёртые Lisp-хакеры старой закалки стремятся избегать этих длинных комбинаций. Однако они присутствуют в старом Lisp-коде, поэтому необходимо представлять, как они работают [151].

Функции FIRST - TENTH , CAR , CADR и т.д. могут также быть использованы как аргумент к SETF , если вы пишите в нефункциональном стиле.

Другие функции для работы со списками ^ Функция Описание ^ LAST Возвращает последнюю cons-ячейку в списке. Если вызывается с целочисленным аргументом n, возвращает n ячеек. | BUTLAST Возвращает копию списка без последней cons-ячейки. Если вызывается с целочисленным аргументом n, исключает последние n ячеек. | NBUTLAST Утилизирующая версия BUTLAST ; может изменять переданный список-аргумент, не имеет строго заданных побочных эффектов. | LDIFF Возвращает копию списка до заданной cons-ячейки. | TAILP Возвращает TRUE если переданный объект является cons-ячейкой, которая является частью списка. | LIST* Строит список, содержащий все переданные аргументы кроме последнего, после этого присваивает CDR последней cons-ячейки списка последнему аргументу. Т.е. смесь LIST и APPEND . | MAKE-LIST Строит список из n элементов. Начальные элементы списка NIL или значение заданное аргументом :initial-element. | REVAPPEND Комбинация REVERSE и APPEND ; инвертирует первый аргумент как REVERSE и добавляет второй аргумент. Не имеет строго заданных побочных эффектов. | NRECONC Утилизирующая версия предыдущей функции; инвертирует первый аргумент как это делает NREVERSE и добавляет второй аргумент. Не имеет строгих побочных эффектов. | CONSP Предикат для тестирования является ли объект cons-ячейкой. | ATOM Предикат для тестирования является ли объект не cons-ячейкой. | LISTP Предикат для тестирования является ли объект cons-ячейкой или NIL | NULL Предикат для тестирования является ли объект NIL . Функционально эквивалентен функции NOT , но стилистически лучше использовать NULL при тестировании является ли список NIL , NOT для проверки булевого выражения FALSE |
<p>Отображение</p>

Другой важный аспект функционально стиля программирования - это использование функций высших порядков, т.е. функций, которые принимают функции в качестве аргументов или возвращают функции. Вы видели несколько примеров функций высших порядков, таких как MAP , в предыдущей главе. Хотя MAP может использоваться как со списками, так и с векторами (т.е. с любым типом последовательностей), Common Lisp также предоставляет 6 функций отображения специально для списков. Разница между этими шестью функциями в том как они строят свой результат и в том применяют ли они переданные функции к элементам списка или к cons-ячейкам списка.

MAPCAR функция наиболее похожая на MAP . Т.к. она всегда возвращает список, она не требует уточнения типа результата, как MAP . Вместо этого, первый аргумент MAPCAR - функция, которую необходимо применить, а последующие аргументы - списки, чьи элементы будут поставлять аргументы для этой функции. Другими словами MAPCAR ведёт себя, как MAP : функция применяется к элементам аргументов-списков, беря от каждого списка по элементу. Результат каждого вызова функции собирается в новый список. Например:

(mapcar #'(lambda (x) (* 2 x)) (list 1 2 3)) ==> (2 4 6)

(mapcar #'+ (list 1 2 3) (list 10 20 30)) ==> (11 22 33)

MAPLIST работает как MAPCAR , только вместо того, чтобы передавать функции элементы списка, она передаёт cons-ячейки [152]. Таким образом, функция имеет доступ не только к значению каждого элемента списка (через функцию CAR , применённую к cons-ячейке), но и к хвосту списка (через CDR ).

MAPCAN и MAPCON работают как MAPCAR и MAPLIST , разница состоит в том, как они строят свой результат. В то время как MAPCAR и MAPLIST строят новый список, содержащий результаты вызова функций, MAPCAN и MAPCON строят результат склеиванием результатов (которые должны быть списками), как это делает NCONC . Таким образом, каждый вызов функции может предоставлять любое количество элементов, для включения в результирующий список [153]. MAPCAN , как MAPCAR , передаёт элементы списка отображающей функции, а MAPCON , как MAPLIST , передаёт cons-ячейки.

Наконец, функции MAPC и MAPL - управляющие структуры, замаскированные под функции, они просто возвращают свой первый аргумент-список, так что они используются только из-за побочных эффектов передаваемой функции. MAPC действует как MAPCAR и MAPCAN , а MAPL как MAPLIST и MAPCON .

<p>Другие структуры</p>

Хотя cons-ячейки и списки обычно трактуются как синонимы, это не всегда так - как я говорил раннее, вы можете использовать списки списков для представления деревьев. Так же как функции из этой главы позволяют вам интерпретировать структуры, построенные из cons-ячеек, как списки, другие функции позволяют использовать cons-ячейки для представления деревьев, множеств, и двух типов отображений ключ-значение. Мы рассмотрим большинство этих функций pcl:нетолькосписки|в следующей главе .

13
<p>13. Не только списки: другие применения cons-ячеек</p>

Как вы увидели в предыдущей главе, списочный тип данных является иллюзией, созданной множеством функций, которые манипулируют cons-ячейками. Common Lisp также предоставляет функции, позволяющие вам обращаться со структурами данных, созданными из cons-ячеек, как c деревьями, множествами, и таблицами поиска ( lookup tables ). В этой главе я дам вам краткий обзор некоторых из этих структур данных и оперирующих с ними функций. Как и в случае функций, работающих со списками, многие из них будут полезны когда вы начнете писать более сложные макросы, где потребуется обращаться с кодом Lisp как с данными.

<p>Деревья</p>

Рассматривать структуры созданные из cons-ячеек как деревья так же естественно, как рассматривать их как списки. Ведь что такое список списков, как не другое представление дерева? Разница между функцией, которая обращается с группой cons-ячеек как со списком, и функцией, обращающейся с той же группой cons-ячеек, как с деревом, определяется тем, какие ячейки обходят эти функции при поиске значений для дерева или списка. Те сons-ячейки, которые обходит функция работы со списком, называются списочной структурой , и располагаются начиная от первой cons-ячейки и затем следуя ссылкам по CDR , пока не достигнут NIL . Элементами списка являются объекты, на которые ссылаются CAR 'ы cons-ячеек списочной структуры. Если cons-ячейка в списочной структуре имеет CAR , который ссылается на другую cons-ячейку, то та ячейка, на которую ведет ссылка, считается головой и элементом внешнего спиcка. [154]Древовидная структура , с другой стороны, при обходе следует ссылкам как по CAR , так и по CDR , пока они указывают на другие cons-ячейки. Таким образом значения в древовидной структуре это атомы - те FIXME значения-не-cons-ячейки, на которые ссылаются CAR 'ы или CDR 'ы cons-ячеек в древовидной структуре.

Например, следующая стрелочная диаграмма показывает cons-ячейки, составляющие список из списков: ((1 2) (3 4) (5 6)) . Списочная структура включает в себя только три cons-ячейки внутри пунктирного блока, тогда как древовидная структура включает все ячейки.

pcl:chapter13:ch13-1.png

Чтобы увидеть разницу между функцией, работающей со списком, и функцией, работающей с деревом, вы можете рассмотреть как функции COPY-LIST и COPY-TREE будут копировать эту группу cons-ячеек. COPY-LIST , как функция, работающая со списком, копирует cons-ячейки, которые составляют списочную структуру. Другими словами, она создает новые cons-ячейки соответственно для каждой из cons-ячеек пунктирного блока. CAR 'ы каждой новой ячейки ссылаются на тот же объект, что и CAR 'ы оригинальных cons-ячеек в списочной структуре. Таким образом, COPY-LIST не копирует подсписки (1, 2) , (3 4) , или (5 6) , как показано на этой диаграмме:

pcl:chapter13:ch13-2.png

COPY-TREE , с другой стороны, создает новые cons-ячейки для каждой из cons-ячеек на диаграмме и соединяет их вместе в одну структуру, как показано на следующей диаграмме:

pcl:chapter13:ch13-3.png

Там где cons-ячейки в оригинале ссылаются на атомарное значение, соответствующие им cons-ячейки в копии будут ссылаться на то же значение. Таким образом, единственными объектами, на которые ссылаются как оригинальное дерево, так и его копия, созданная COPY-TREE , будут числа 5 , 6 , и символ NIL .

Еще одна функция, которая обходит как CAR 'ы так и CDR 'ы cons-ячеек дерева это TREE-EQUAL , которая сравнивает два дерева, и считает их равными, если их структуры имеют одинаковую форму и если их листья равны относительно EQL (или если они удовлетворяют условию, задаваемому через именованный аргумент :test ).

Прочими ориентированными на деревья функциями являются работающие с деревьями аналоги функций для последовательностей SUBSTITUTE и NSUBSTITUTE , и их -IF и -IF-NOT варианты. Функция SUBST , как и SUBSTITUTE , принимает новый элемент, старый элемент и дерево (в отличие от последовательности), вместе с именованными аргументами :key и :test , и возвращает новое дерево с той же формой, что и исходное, но все вхождения старого элемента заменяются новым. Например:

CL-USER> (subst 10 1 '(1 2 (3 2 1) ((1 1) (2 2))))

(10 2 (3 2 10) ((10 10) (2 2)))

SUBST-IF аналогична SUBSTITUTE-IF . Но вместо старого элемента, она принимает одноаргументную функцию, которая вызывается с аргументом в виде каждого атомарного значения в дереве, и всякий раз, когда она возвращает истину, текущая позиция в новом дереве заменяется новым значением. SUBST-IF-NOT действует также, за исключением того, что заменяются те значения, где функция возвращает NIL . NSUBST , NSUBST-IF , и NSUBST-IF-NOT - это утилизирующие аналоги соответствующих версии SUBST -функций. Как и с другими утилизирующими функциями, вам следует использовать эти функции только как временную FIXME (drop-in - небезопасную?) замену их недеструктивных аналогов, в ситуациях, где вы уверены, что нет опасности повреждения разделяемой структуры. В частности, вы должны продолжать сохранять возвращаемые значения этих функции пока у вас нет гарантии, что результат будет равен по предикату EQ оригинальному дереву. [155]

<p>Множества</p>

Множества также могут быть реализованы посредством cons-ячеек. Фактически, вы можете обращаться с любым списком как с множеством - Common Lisp предоставляет несколько функций для выполнения теоретико-множественных операций над списками. Тем не менее вам следует помнить, что из-за особенности устройства списков эти операции становятся тем менее и менее эффективными, чем больше становится множество.

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

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

ADJOIN также принимает именованные аргументы :key и :test , которые используются при определении - присутствует ли элемент в исходном списке. Подобно CONS , ADJOIN не воздействует на исходный список - если вы хотите модифицировать определенный список, вам нужно присвоить значение, возвращаемое ADJOIN , тому FIXME месту где находился исходный список. Деструктивный макрос PUSHNEW сделает это для вас автоматически.

CL-USER> (defparameter *set* ())

*SET*

CL-USER> (adjoin 1 *set*)

(1)

CL-USER> *set*

NIL

CL-USER> (setf *set* (adjoin 1 *set*))

(1)

CL-USER> (pushnew 2 *set*)

(2 1)

CL-USER> *set*

(2 1)

CL-USER> (pushnew 2 *set*)

(2 1)

Вы можете проверить, принадлежит ли данный элемент множеству с помощью функции MEMBER и родственных ей функций MEMBER-IF и MEMBER-IF-NOT . Эти функции похожи на функции для работы с последовательностями - FIND , FIND-IF , и FIND-IF-NOT , за исключением того, что они используются только со списками. Вместо того, чтобы вернуть элемент, когда он присутствует в множестве, они возвращают cons-ячейку содержащую элемент - другими словами, подсписок начинающийся с заданного элемента. Если искомый элемент отсутствует в списке, все три функции возвращают NIL .

Оставшиеся ориентированные на множества функции предоставляют операции с группами элементов: INTERSECTION , UNION , SET-DIFFERENCE , и SET-EXCLUSIVE-OR . Каждая из этих функций принимает два списка и именованные аргументы :key и :test и возвращает новый список, представляющий множество, полученное выполнением соответствующей операции над двумя списками. INTERSECTION возвращает список, содержащий все аргументы из обоих списков. UNION возвращает список, содержащий один экземпляр каждого уникального элемента из двух своих аргументов [156] SET-DIFFERENCE возвращает список, содержащий все элементы из первого аргумента, которые не встречаются во втором аргументе. И SET-EXCLUSIVE-OR возвращает список, содержащий элементы, находящиеся только в одном либо в другом списках, переданных в качестве аргументов, но не в обоих одновременно. Каждая из этих функций также имеет утилизирующий аналог, имя которого получается добавлением N в качестве префикса.

В заключение, функция SUBSETP принимает в качестве аргументов два списка и обычные именованные аргументы :key и :test и возвращает истину, если первый список является подмножеством второго - то есть, если каждый элемент первого списка также присутствует во втором списке. Порядок элементов в списках значения не имеет.

CL-USER> (subsetp '(3 2 1) '(1 2 3 4))

T

CL-USER> (subsetp '(1 2 3 4) '(3 2 1))

NIL

<p>Таблицы поиска: ассоциативные списки и списки свойств</p>

Помимо деревьев и множеств вы можете создавать таблицы, которые отображают ключи на значения вне cons-ячеек. Обычно используются две разновидности основанных на cons-ячейках таблиц поиска, об обеих я вскользь упоминал в предыдущих главах. Это ассоциативные списки ( association lists или alists ) и списки свойств ( property lists или plists ). Вам не стоит использовать списки свойств или ассоциативные списки для больших таблиц - для них нужно использовать хэш-таблицы, но стоит знать, как работать с ними обоими, так как для небольших таблиц они могут быть более эффективны, чем хэш-таблицы, и еще потому, что у них есть несколько полезных собственных свойств. Ассоциативный список - это структура данных, которая отображает ключи на значения, а также поддерживает обратный поиск, находя ключ по заданному значению. Ассоциативные списки также поддерживают возможность добавления отображений ключ/значение, которые скрывают существующие отображения таким образом, что скрывающие отображения могут быть позже удалены и первоначальные отображения снова станут видимы.

Если смотреть глубже, то на самом деле ассоциативный список - это просто список, чьи элементы сами является cons-ячейками. Каждый элемент можно представлять как пару ключ/значение с ключом в CAR cons-ячейки и значением в CDR . К примеру, следующая стрелочная диаграмма представляет ассоциативный список, состоящий из отображения символа A в номер 1 , B в номер 2 , и C в номер 3 :

pcl:chapter13:ch13-4.png

До тех пор, пока значение CDR является списком, cons-ячейки представляющие пары ключ/значение будут точечными парами в терминах s-выражений. Ассоциативный список, представленный на предыдущей диаграмме, к примеру, будет напечатан вот так:

((A . 1) (B . 2) (C . 3))

Главная процедура поиска для ассоциативных списков это ASSOC , которая принимает ключ и ассоциативный список в качестве аргументов, и возвращает первую cons-ячейку, чей CAR соответствует ключу или является NIL , если совпадения не найдено.

CL-USER> (assoc 'a '((a . 1) (b . 2) (c . 3)))

(A . 1)

CL-USER> (assoc 'c '((a . 1) (b . 2) (c . 3)))

(C . 3)

CL-USER> (assoc 'd '((a . 1) (b . 2) (c . 3)))

NIL

Чтобы получить значение, соответствующее заданному ключу, вам следует просто передать результат ASSOC СDR 'у.

CL-USER> (cdr (assoc 'a '((a . 1) (b . 2) (c . 3))))

1

По умолчанию заданный ключ сравнивается с ключами в ассоциативном списке, используя предикат EQL , но вы можете изменить его с помощью стандартной комбинации из именованных аргументов :key и :test . Например, если вы хотите использовать строковые ключи, то можете написать так:

CL-USER> (assoc "a" '(("a" . 1) ("b" . 2) ("c" . 3)) :test #'string=)

("a" . 1)

Без явного задания в качестве :test предиката STRING= ASSOC вероятно вернул бы NIL , потому что две строки с одинаковым содержимым необязательно равны относительно EQL .

CL-USER> (assoc "a" '(("a" . 1) ("b" . 2) ("c" . 3)))

NIL

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

CL-USER> (assoc 'a '((a . 10) (a . 1) (b . 2) (c . 3)))

(A . 10)

Вы можете добавить пару в начало списка с помощью функции CONS , как здесь:

(cons (cons 'new-key 'new-value) alist)

Однако для удобства Common Lisp предоставляет функцию ACONS , которая позволяет вам написать так:

(acons 'new-key 'new-value alist)

Подобно CONS , ACONS является функцией и, следовательно, не может модифицировать место, откуда был передан исходный ассоциативный список. Если вы хотите модифицировать ассоциативный список, вам нужно написать так:

(setf alist (acons 'new-key 'new-value alist))

или так:

(push (cons 'new-key 'new-value) alist)

Очевидно, время затраченное на поиск в ассоциативном списке при использовании ASSOC , является функцией от того, насколько глубоко в списке находится соответствующая пара. В худшем случае для определения, что никакая пара не соответствует искомой, ASSOC требуется просмотреть каждый элемент списка. Тем не менее, поскольку основной механизм работы ассоциативных списков довольно прост, то на небольших таблицах ассоциативный список может превзойти в производительности хэш-таблицу. Также ассоциативные списки могут дать вам большую гибкость при выполнении поиска. Ранее я отмечал, что ASSOC принимает именованные аргументы :key и :test . Если они не соответствуют вашим требованиям, вы можете использовать функции ASSOC-IF и ASSOC-IF-NOT , которые возвращают первую пару ключ/значение, чей CAR удовлетворяет (или не удовлетворяет, в случае ASSOC-IF-NOT ) предикату, передаваемому вместо ключа. Еще три функции - RASSOC , RASSOC-IF , и RASSOC-IF-NOT действуют так же, как и соответствующие аналогичные ASSOC -функции, за исключением того, что они используют значение в CDR каждого элемента как ключ, совершая обратный поиск.

Функция COPY-ALIST похожа на COPY-TREE за исключением того, что вместо копирования всей древовидной структуры, она копирует только те cons-ячейки, которые составляют списочную структуру, плюс те cons-ячейки, на которые ссылаются CAR 'ы этих ячеек. Другими словами, исходный ассоциативный список и его копия будут оба содержать одинаковые объекты как в виде ключей, так и значений, даже если ключи или значения будут состоять из cons-ячеек.

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

CL-USER> (pairlis '(a b c) '(1 2 3))

((C . 3) (B . 2) (A . 1))

Или вы можете получить следующее:

CL-USER> (pairlis '(a b c) '(1 2 3))

((A . 1) (B . 2) (C . 3))

Другой разновидностью таблицы поиска является список свойств (property list или сокращенно plist), который вы использовали для представления строк базы данных в Главе 3. Структурно список свойств есть просто обычный список с ключами и значениями в виде чередующихися величин. К примеру, список свойств отображающий A , B , и C , на 1 , 2 , и 3 это просто список (A 1 B 2 C 3) . На стрелочной диаграмме он выглядит так:

pcl:chapter13:ch13-5.png

Впрочем списки свойств является менее гибкими чем ассоциативные списки. В действительности список свойств поддерживает только одну фундаментальную операцию поиска, функцию GETF , которая принимает список свойств и ключ и возвращает связанное с ключом значение или NIL , если ключ не найден. GETF также принимает необязательный третий аргумент, который возвращается вместо NIL , если ключ не найден.

В отличие от ASSOC , которая использует EQL как проверочный предикат по умолчанию и позволяет использовать другой проверочный предикат в виде именованного аргумента :test , GETF всегда использует EQ для проверки, совпадает ли переданный ей ключ с ключами списка свойств. Следовательно, вам никогда не следует использовать числа или знаки в качестве ключей в списке свойств; как вы видели в Главе 4, поведение EQ для этих типов данных фактически не определено. На практике, ключи в списке свойств почти всегда являются символами, с тех пор как списки свойств были впервые изобретены для реализации "свойств" символов, то есть произвольных отображений между именами и значениями.

Вы можете использовать SETF вместе с GETF для установки переменной с заданным ключом. SETF также обращается с GETF немного особым образом, при котором первый аргумент GETF считается модифицируемым. Таким образом вы можете вызвать SETF поверх GETF для добавления новых пар ключ/значение к существующему списку свойств.

CL-USER> (defparameter *plist* ())

*PLIST*

CL-USER> *plist*

NIL

CL-USER> (setf (getf *plist* :a) 1)

1

CL-USER> *plist*

(:A 1)

CL-USER> (setf (getf *plist* :a) 2)

2

CL-USER> *plist*

(:A 2)

Чтобы удалить пару ключ/значение из списка свойств, вы можете использовать макрос REMF , который присваивает месту, переданному в качестве своего первого аргумента, список свойств, содержащий все пары ключ/значение, за исключением заданной. Он возвращает истину если заданный ключ был найден.

CL-USER> (remf *plist* :a)

T

CL-USER> *plist*

NIL

Как и GETF , REMF всегда использует EQ для сравнения заданного ключа с ключами в списке свойств.

Поскольку списки свойств часто используются в ситуациях, когда вы хотите извлечь несколько значений свойств из одного и того же списка, Common Lisp предоставляет функцию GET-PROPERTIES , которая делает более эффективным извлечение нескольких значений из одного списка свойств. Она принимает список свойств и список ключей для поиска, и возвращает, в виде множества значений ( multiple values ), первый найденный ключ, соответствующее ему значение, и голову списка, начинающегося с этого ключа. Это позволяет вам обработать список свойств, извлекая из него нужные свойства, без продолжительного повторного поиска с начала списка. К примеру, следующая функция эффективно обрабатывает, используя гипотетическую функцию process-property , все пары ключ/значение в списке свойств для заданного списка ключей:

(defun process-properties (plist keys)

(loop while plist do

(multiple-value-bind (key value tail) (get-properties plist keys)

(when key (process-property key value))

(setf plist (cddr tail)))))

Последней особенностью списков свойств является их отношение к символам: каждый символический объект имеет связанный список свойств, который может быть использован для хранения информации о символе. Список свойств может быть получен с помощью функции SYMBOL-PLIST . Тем не менее, обычно вам редко необходим весь список свойств; чаще вы будете использовать функцию GET , которая принимает символ и ключ и выполняет роль сокращенной записи для GETF с аргументами в виде того же ключа и списка символов, возвращаемого SYMBOL-PLIST .

(get 'symbol 'key) === (getf (symbol-plist 'symbol) 'key)

Как с GETF , к возвращаемому значению GET можно применить SETF , так что вы можете присоединить произвольную информацию к символу, как здесь:

(setf (get 'some-symbol 'my-key) "information")

Чтобы удалить свойство из списка свойств символа, вы можете использовать либо REMF поверх SYMBOL-PLIST или удобную функцию REMPROP [157]

(remprop 'symbol 'key) === (remf (symbol-plist 'symbol) 'key)

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

<p>DESTRUCTURING-BIND</p>

Последний инструмент для разделки и нарезки списков, о котором я должен рассказать, поскольку он понадобится вам в дальнейших главах - это макрос DESTRUCTURING-BIND . Этот макрос предоставляет способ FIXME деструктурировать произвольные списки, подобно тому, как списки параметров макроса могут разбирать на части свои списки аргументов . Основной скелет DESTRUCTURING-BIND таков:

(destructuring-bind (parameter*) list

body-form*)

Список параметров может включать любые из типов параметров, поддерживаемых в списках параметров макросов, таких как &optional , &rest , и &key [158]. Как и в списке параметров макроса, любой параметр может быть заменен на вложенный деструктурирующий список параметров, который разделяет список на составные части, иначе список целиком был бы связан с замененным параметом. Форма list вычисляется один раз и возвращает список, который затем деструктурируется и соответствующие значения связываются с переменными в списке параметров. Затем после по порядку вычисляются все body-form с учетом значений связанных переменных. Вот несколько простых примеров:

(destructuring-bind (x y z) (list 1 2 3)

(list :x x :y y :z z)) ==> (:X 1 :Y 2 :Z 3)

(destructuring-bind (x y z) (list 1 (list 2 20) 3)

(list :x x :y y :z z)) ==> (:X 1 :Y (2 20) :Z 3)

(destructuring-bind (x (y1 y2) z) (list 1 (list 2 20) 3)

(list :x x :y1 y1 :y2 y2 :z z)) ==> (:X 1 :Y1 2 :Y2 20 :Z 3)

(destructuring-bind (x (y1 &optional y2) z) (list 1 (list 2 20) 3)

(list :x x :y1 y1 :y2 y2 :z z)) ==> (:X 1 :Y1 2 :Y2 20 :Z 3)

(destructuring-bind (x (y1 &optional y2) z) (list 1 (list 2) 3)

(list :x x :y1 y1 :y2 y2 :z z)) ==> (:X 1 :Y1 2 :Y2 NIL :Z 3)

(destructuring-bind (&key x y z) (list :x 1 :y 2 :z 3)

(list :x x :y y :z z)) ==> (:X 1 :Y 2 :Z 3)

(destructuring-bind (&key x y z) (list :z 1 :y 2 :x 3)

(list :x x :y y :z z)) ==> (:X 3 :Y 2 :Z 1)

Единственный вид параметра, который вы можете использовать как с DESTRUCTURING-BIND , так и в списках параметров макросов, о котором я не упомянул в Главе 8, это параметр &whole . Если он указан, то располагается первым в списке параметров, и связывается со всей формой списка целиком [159]. После параметра &whole , другие параметры могут появляться как обычно, и извлекать определенные части списка, как если бы параметр &whole отсутствовал. Пример использования &whole вместе с DESTRUCTURING-BIND выглядит так:

(destructuring-bind (&whole whole &key x y z) (list :z 1 :y 2 :x 3)

(list :x x :y y :z z :whole whole))

==> (:X 3 :Y 2 :Z 1 :WHOLE (:Z 1 :Y 2 :X 3))

Вы будете использовать параметр &whole в одном из макросов, составляющих часть библиотеки для генерации HTML, которую вы разработаете в Главе 31. Однако мне нужно рассмотреть еще несколько вопросов, прежде чем вы приступите к ней. После двух глав довольно лисповских тем про cons-ячейки, вы можете перейти к более скучным вопросам о том, как работать с файлами и именами файлов.

14
<p>14. Файлы и файловый ввод/вывод</p>

Common Lisp предоставляет мощную библиотеку для работы с файловыми операциями. В этой главе я остановлюсь на нескольких основных операциях, относящихся к работе с файлами: чтение и запись файлов, а так же отображение структуры файловой системы. Возможности Common Lisp, предназначенные для данных операций, аналогичны тем, что предоставляют другие языки программирования. Для чтения и записи файлов Common Lisp предоставляет абстракцию потока ввода/вывода, а для манипулирования файловыми объектами в независимом от операционной системы формате - абстракцию файловых путей . Кроме того, Common Lisp предоставляет некоторое количество уникальных возможностей, например, такую как чтение и запись s-выражений.

<p>Чтение данных из файлов</p>

Основная операция работы с файловым вводом/выводом - чтение содержимого. Для того, чтобы получить поток, из которого вы можете прочитать содержимое файла, используется функция OPEN . По умолчанию, OPEN возвращает посимвольный поток ввода данных, который можно передать множеству функций, которые читают один или несколько символов текста: READ-CHAR читает одиночный символ; READ-LINE читает строку текста, возвращая ее как строку без символа конца строки; функция READ читает s-выражение, возвращая объект Lisp. Если работа с потоком завершена, то вы можете закрыть его с помощью функции CLOSE .

Функция OPEN требует имя файла как единственный обязательный аргумент. Как можно увидеть в секции "Имена файлов", Common Lisp предоставляет два пути для представления имени файла, но наиболее простой способ - использовать строку, содержащую имя в формате, используемом в файловой системе. Так, предполагая, что /some/file/name.txt это файл, возможно открыть его следующим образом:

(open "/some/file/name.txt")

Вы можете использовать объект, возвращаемый функцией как первый аргумент любой функции, осуществляющей чтение. Например, для того, чтобы напечатать первую строку файла, вы можете комбинировать OPEN , READ-LINE , CLOSE следующим образом:

(let ((in (open "/some/file/name.txt")))

(format t "~a~%" (read-line in))

(close in))

Конечно, при открытии и чтении данных может произойти ряд ошибок. Файл может не существовать, или вы можете непредвиденно достигнуть конца файла в процессе его чтения. По умолчанию, OPEN и READ-* будут сигнализировать об ошибках в данных ситуациях. В главе 19, я рассмотрю как обработать эти ошибки. Сейчас же, однако, будем использовать более легковесное решение: каждая из этих функций принимает аргументы, которые изменяют ее реакцию на исключительные ситуации.

Если вы хотите открыть файл, который возможно не существует без генерирования ошибки функцией OPEN , вы можете использовать аргумент :if-does-not-exists для того, чтобы указать другое поведение. Три различных значения допустимы для данного аргумента - :error , по умолчанию; :create , что указывает на необходимость создания файла и осуществить повторное его открытие второй раз как существующего и NIL , что означает возврат NIL при неуспешном открытии вместо потока. Итак, возможно изменить предыдущий пример таким образом, чтобы обработать несуществующий файл.

(let ((in (open "/some/file/name.txt" :if-does-not-exist nil)))

(when in

(format t "~a~%" (read-line in))

(close in)))

Все функции чтения - READ-CHAR , READ-LINE , READ - принимают вспомогательный аргумент, который имеет значение истины по умолчанию, указывающий, должны ли они сигнализировать об ошибке, если они достигли конца файла. Если этот аргумент установлен в NIL , то они возвращают значение их 3го аргумента, который по умолчанию NIL , вместо ошибки. Таким образом, вывести на печать все строки файла можно следующим способом:

(let ((in (open "/some/file/name.txt" :if-does-not-exist nil)))

(when in

(loop for line = (read-line in nil)

while line do (format t "~a~%" line))

(close in)))

Среди трех вышерассмотренных функций READ уникальна. Это та самая функция, которая представляет букву "R" в "REPL", и которая используется для того, чтобы читать исходный код Lisp. Во время вызова она читает целое s-выражение, пропуская пробельные символы и комментарии, и возвращает объект Lisp, представляемый s-выражением. Например, предположим, что файл /some/file/name.txt имеет следующее содержимое:

(1 2 3)

456

"строка" ; это комментарий

((a b)

(c d))

Другими словами, он содержит 4 s-выражения: список чисел, число, строку, и список списков. Вы можете читать эти выражения следующим образом:

CL-USER> (defparameter *s* (open "/some/file/name.txt"))

*S*

CL-USER> (read *s*)

(1 2 3)

CL-USER> (read *s*)

456

CL-USER> (read *s*)

"строка"

CL-USER> (read *s*)

((A B) (C D))

CL-USER> (close *s*)

T

Как было показано в Главе 3, возможно использовать PRINT для того, чтобы выводить объекты Lisp на печать в удобной для прочтения форме. Итак, когда необходимо хранить данные в файлах, PRINT и READ предоставляют простой способ делать это без создания специального формата данных и парсера для их прочтения. Вы даже можете использовать комментарии без ограничений. И, поскольку s-выражения создавались для того, чтобы быть редактируемыми людьми, то они так же хорошо подходят для использования в качестве формата конфигурационных файлов [160].

<p>Чтение двоичных данных</p>

По умолчанию OPEN возвращает символьные потоки, которые преобразуют байты в символы в соответствии с конкретной схемой кодирования символов [161].

Для чтения потока байтов необходимо передать функции OPEN ключевой параметр :element-type со значением '(unsigned-byte 8) [162] Полученный поток можно передать функции READ-BYTE , которая будет возвращать целое число от 0 до 255 во время каждого вызова. READ-BYTE , так же, как и функции, работающие с потоками символов, принимает опциональные аргументы, которые указывают, должна ли она сигнализировать об ошибке, если достигнут конец файла, и какое значение возвращать в противном случае. В главе 24 мы построим библиотеку, которая позволит удобно читать структурированные бинарные данные, используя READ-BYTE . [163]

<p>Блочное чтение</p>

Последняя функция для чтения, READ-SEQUENCE , работает с бинарными и символьными потоками. Ей передается последовательность (обычно вектор) и поток,и она пытается заполнить последовательность данными из потока. Функция возвращает индекс первого элемента последовательности, который не был заполнен, либо ее длину, если она была заполнена полностью. Так же возможно передать ключевые аргументы :start и :end , которые указывают на подпоследовательность, которая должна быть заполнена вместо последовательности. Аргумент, определяющий последовательность должен быть типом, который может хранить элементы, определенного для потока типа. Поскольку большинство операционных систем поддерживают какую-либо форму блочных операций ввода/вывода, READ-SEQUENCE скорее всего более эффективна чем чтение последовательных данных несколькими вызовами READ-BYTE или READ-CHAR .

<p>Файловый вывод</p>

Для записи данных в файл необходим поток вывода, который можно получить вызовом функции OPEN с ключевым аргументом :direction :output . Когда файл открывается для записи OPEN предполагает что файл не должен существовать и будет сообщать об ошибке в противном случае. Однако, возможно изменить это поведение с помощью ключевого аргумента :if-exists . Передавая значение :supersede можно вызвать замену существующего файла. Значение :append позволяет осуществлять запись таким образом, что новые данные будут помещены в конец файла, а значение :overwrite возвращает поток, который будет переписывать существующие данные с начала файла. Если же передать NIL , то OPEN вернет NIL вместо потока, если файл уже существует. Характерное использование OPEN для вывода данных выглядит следующим образом:

(open "/some/file/name.txt" :direction :output :if-exists :supersede)

Common Lisp также предоставляет некоторые функции для записи данных: WRITE-CHAR пишет одиночный символ в поток. WRITE-LINE пишет строку, за которой следует символ конца строки, с учетом реализации для конкретной платформы. Другая функция, WRITE-STRING пишет строку, не добавляя символ конца строки. Две разные функции могут использоваться для того чтобы вывести символ конца строки: TERPI - сокращение для "TERminate PRInt" (закончить печать) безусловно печатает символ конца строки, а FRESH-LINE печатает символ конца строки только в том случае, если текущая позиция печати не совпадает с началом строки. FRESH-LINE удобна в том случае, когда желательно избежать паразитных пустых строк в текстовом выводе, генерируемом другими последовательно вызываемыми функциями. Например, допустим, что есть одна функция, которая генерирует вывод и после которой обязательно должен идти перенос строки и другая, которая должна начинаться с новой строки. Но, предположим, что если функции вызываются последовательно, то необходимо обеспечить отсутствие лишних пустых строк в их выводе. Если в начале второй функции используется FRESH-LINE , ее вывод будет постоянно начинать с новой строки, но если она вызывается непосредственно после первой функции, то не будет образовываться дополнительной пустой строки.

Некоторые функции позволяют вывести данные Lisp в форме s-выражений: PRINT печатает s-выражение, предваряя его символом начала строки, и пробельным символом после. PRIN1 печатает только s-выражение. И функция PPRINT печатает s-выражения как PRINT и PRIN1 , но используя "красивую печать", которая пытается печатать s-выражения в удобном для восприятия виде.

Но не все объекты могут быть напечатаны в том формате, который понимает READ . Переменная *PRINT-READABLY* контролирует поведение при попытке напечатать какой-либо подобный объект с помощью PRINT , PRIN1 или PPRINT . Когда она равна NIL , эти функции напечатают объект в таком формате, что READ сообщит об ошибке при попытке чтения; в ином случае они просигнализируют об ошибке вместо того, чтобы напечатать объект.

Еще одна функция, PRINC , также печатает объекты Лиспа, но в виде, удобном для человеческого восприятия. Например, PRINC печатает строки без кавычек. Текстовый вывод может быть еще более гибким и искусным, если задействовать — отчасти загадочную — функцию FORMAT . Я расскажу о некоторых важных деталях этой функции, которая, в общем-то, определяет мини-язык для форматированного вывода, в 18 главе.

Для того, чтобы записать двоичные данные в файл, следует открыть файл функцией OPEN с тем же самым аргументом :element-type , который использовался при чтении данных: '(unsigned-byte 8) . После этого можно записывать в поток отдельные байты функцией WRITE-BYTE .

Функция блочного вывода WRITE-SEQUENCE принимает как двоичный, так и символьный потоки до тех пор, пока все элемента последовательности подходящего типа: либо символы, либо байты. Так же как и READ-SEQUENCE , эта функция наверняка более эффективна, чем запись одного элемента последовательности за один вызов функции.

<p>Закрытие файлов</p>

Любой, кто писал программы, взаимодействующие с файлами, знает, что важно закрывать файлы, когда работа с ними закончена, так как дескрипторы норовят быть дефицитным ресурсом. Если открывают файлы и забывают их закрывать, вскоре обнаруживают, что больше нельзя открыть ни одного файла [164]. На первый взгляд может показаться, что достаточно каждый вызов OPEN сопоставить с вызовом CLOSE . Например, можно всегда обрамлять код, использующий файл, как показано ниже:

(let ((stream (open "/some/file/name.txt")))

;; работа с потоком

(close stream))

Однако этом метод имеет две проблемы. Первая — он предрасположен к ошибкам: если забыть написать CLOSE , то будет происходить утечка дескрипторов при каждом вызове этого кода. Вторая наиболее значительная нет гарантии, что CLOSE будет достигнут. Например, если в коде, расположенном до CLOSE , есть RETURN или RETURN-FROM , возвращение из LET произойдет без закрытия потока. Или, как вы увидите в 19 главе, если какая-либо часть кода до CLOSE сигнализирует об ошибке, управление может перейти за пределы LET обработчику ошибки и никогда не вернется, чтобы закрыть поток.

Common Lisp предоставляет общее решение того, как удостовериться, что определенный код всегда исполняется: специальный оператор UNWIND-PROTECT , о котором я расскажу в 20 главе. Так как открытие файла, работа с ним и последующее закрытие очень часто употребляются, Common Lisp предлагает макрос, WITH-OPEN-FILE , основанный на UNWIND-PROTECT , для скрытия этих действий. Ниже — основная форма:

(with-open-file (stream-var open-argument*)

body-form*)

Выражения в body-forms* вычисляются с stream-var , связанной с файловым потоком, открытым вызовом OPEN с аргументами open-argument* . WITH-OPEN-FILE удостоверяется, что поток stream-var закрывается до того, как из WITH-OPEN-FILE вернется управление. Поэтому читать файл можно следующим образом:

(with-open-file (stream "/some/file/name.txt")

(format t "~a~%" (read-line stream)))

Создать файл можно и так:

(with-open-file (stream "/some/file/name.txt" :direction :output)

(format stream "Какой-то текст."))

Как правило, WITH-OPEN-FILE используется в 90-99 процентах файлового ввода/вывода. Вызовы OPEN и CLOSE понадобятся, если файл нужно открыть в какой-либо функции и оставить поток открытым при возврате из нее. В любом случае, нужно позаботиться о закрытии потока впоследствии, или произойдет утечка файловых дескрипторов и, в конце концов, вы больше не сможете окрыть ни одного файла.

<p>Имена файлов</p>

До сих пор мы использовали строки для представления имен файлов. Однако, использование строк как имен файлов привязывает код к конкретной операционной и файловой системам. Точно так же, если конструировать имена в соответствии правилам конкретной схемы именования (скажем, разделение директорий знаком "/"), то вы также привязаны к одной определенной файловой системе.

Для того, чтобы избежать подобной непереносимости программ, Common Lisp предоставляет другое представление файловых имен: объекты файловых путей. Файловые пути представляют собой файловые имена в структурированном виде, что делает их использование легким без привязки к определенному синтаксису файловых имен. А бремя по переводу между строками в локальном представлении строками имен и файловыми путями ложится на плечи реализаций Lisp.

К несчастью, как и со многими абстракциями, спроектированных для скрытия деталей различных базовых систем, абстракция файловых путей привносит свои трудности. В то время, когда разрабатывались файловые пути, множество файловых систем было значительно больше, чем сегодня. Но это мало проясняет ситуацию, если все, о чем вы заботитесь, представление имен файлов в Unix или Windows. Однако однажды поняв какие части этой абстракции можно забыть, как артефакты истории развития файловых путей, вы сможете ловко управлять именами файлов [165].

Как правило, когда возникает необходимость в файловом имени, вы можете импользовать как строку имени (namestring), так и файловый путь. Выбор зависит от того, откуда произошло имя. Файловые имена, предоставленные пользователем например, как аргументы или строки конфигурационного файла как правило, будут строками имен, так как пользователь знает какая операционная система у него запущена, поэтому не следует ожидать, что он будет беспокоиться о представлении файловых имен в Lisp. Но следуя общепринятой практике, файловые имена будут представлены файловыми путями, так как они переносимы. Поток, который возвращает OPEN , также представляет файловое имя, а именно, файловое имя, которое было изначально использовано для открытия этого потока. Вместе эти три типа упоминаются как указатели файловых путей. Все встроенные функции, ожидающие файловое имя как аргумент, принимают все три типа указателя файловых путей. Например, во всех предыдущих случаях, когда вы использовали строку для представления файлового имени, вы также могли передавать в функцию объект файлового пути или поток.

<p>Как мы до этого докатились</p>

Историческое разнообразие файловых систем, существующих в период 70-80 годов, можно легко забыть. Кент Питман, один из ведущих технических редакторов стандарта Common Lisp, описал однажды ситуацию в comp.lang.lisp (Message-ID: sfwzo74np6w.fsf@world.std.com ) так:

В момент завершения проектирования Common Lisp господствующими файловыми системами были TOPS-10 , TENEX , TOPS-20 , VAX VMS , AT&T Unix , MIT Multics , MIT ITS , и это не упоминаю группу систем для мэйнфрэймов. В некоторых системах имена файлов были только в верхнем регистре, в других смешанные, в третьих чувствительны к регистру, но с возможностью преобразования (как в CL). Какие-то имели групповые символы (wildcards), какие-то нет. Одни имели :вверх ( :up ) в относительных файловых путях, другие нет. Также существовали файловые системы без каталогов, файловые системы без иерархической структуры каталогов, файловые системы без типов файлов, файловые системы без версий, файловые системы без устройств и т.д.

Если сейчас посмотреть на абстракцию файловых путей с точки зрения какой-нибудь определенной файловой системы, она выглядит нелепо. Но если взять в рассмотрении даже такие две похожие файловые системы, как в Windows и Unix, то вы можете заметить отличия, от которых можно отвлечься с помощью системы файловых путей. Файловые имена в Windows содержат букву диска в то время, как в Unix нет. Другое преимущество владения абстракцией файловых путей, которая спроектирована, чтобы оперировать большим разнообразием файловых систем, которые существовали в прошлом, ее вероятная способность управлять файловыми системами, которые будут существовать в будущем. Если, скажем, файловые системы с сохранением всех старых данных и истории операций войдут снова в моду, Common Lisp будет к этому готов.

<p>Как имена путей представляют имена файлов</p>

Файловый путь это структурированный объект, который представляет файловое имя, используя шесть компонентов: хост, устройство, каталог, имя, тип и версия. Большинство из них принимают атомарные значения, как правило, строки; только директория структурный компонент, содержащий список имен каталогов (как строки) с предшествующим ключевым словом: :absolute (абсолютный) или :relative (относительный). Но не все компоненты необходимы на все платформах это одна из тех вещей, которая вселяет страх в начинающих лисперов, потому что необоснованно сложна. С другой стороны, вам не надо заботиться о том, какие компоненты могут или нет использоваться для представления имен на определенной файловой системе, если только вам не надо создать объект файлового пути с нуля, а это почти никогда и не надо. Взамен, вы обычно получите объект файлового пути, либо позволив реализации преобразовать строку имени, специфичной для какой-то файловой системы, в объект файлового пути, либо создав файловый путь, который перенимает большинство компонент от какого-либо существующего.

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

На файловых системах Unix, обычно, используются компоненты директория, имя и тип. В Windows на один компонент больше обычно устройство или хост содержит букву диска. На этих платформах строку имени делят на части, а разделителем служит косая черта в Unix и косая или обратная косая черты в Windows. Букву диска в Windows размещают либо в компонент устройства или компонент хост. Все, кроме последнего из оставшихся элементов имени, размещаются в списке, начинающемся с :absolute или :relative в зависимости от того, начинается ли имя с разделителя или нет (игнорирую букву диска, если таковая присутствует). Список становится компонентом каталог файлового пути. Последний элемент делится по самой крайней точке, если она есть, и полученные две части есть компоненты имя и тип, соответственно. [166]

Можно проверить каждый компонент файлового пути с функциями PATHNAME-DIRECTORY , PATHNAME-NAME и PATHNAME-TYPE .

(pathname-directory (pathname "/foo/bar/baz.txt")) → (:ABSOLUTE "foo" "bar")

(pathname-name (pathname "/foo/bar/baz.txt")) → "baz"

(pathname-type (pathname "/foo/bar/baz.txt")) → "txt"

Другие три функции PATHNAME-HOST , PATHNAME-DEVICE и PATHNAME-VERSION позволяют получить остальные три составляющие файлового пути, хотя они и не представляют интереса в Unix. В Windows либо PATHNAME-HOST , либо PATHNAME-DEVICE возвратит букву диска.

Подобно другим встроенным объектам, файловые пути обладают своим синтаксисом для чтения: #p , за которым следует строка, заключенная в двойные кавычки. Это позволяет печатать и считывать s-выражения, содержащие объекты файлового пути, но так как синтаксис зависит от алгоритма анализа строки, эти данные могут быть непереносимыми между разными операционными системами.

(pathname "/foo/bar/baz.txt") → #p"/foo/bar/baz.txt"

Для того, чтобы файловый путь преобразовать обратно в строку имени например, чтобы представить его пользователю следует воспользоваться функцией NAMESTRING , которая принимает указатель файлового пути и возвращает строку имени. Две других функции DIRECTORY-NAMESTRING и FILE-NAMESTRING возвращают часть строки имени. DIRECTORY-NAMESTRING соединяет элементы компонента каталог в локальное имя каталога. FILE-NAMESTRING компоненты имя и тип. [167]

(namestring #p"/foo/bar/baz.txt") → "/foo/bar/baz.txt"

(directory-namestring #p"/foo/bar/baz.txt") → "/foo/bar/"

(file-namestring #p"/foo/bar/baz.txt") → "baz.txt"

<p>Конструирование имен путей</p>

Вы можете создать файловый путь, используя функцию MAKE-PATHNAME . Она принимает по одному аргументу-ключу на каждую компоненту файлового пути и возвращает файловый путь, заполненный всеми предоставленными компонентыми. [168]

(make-pathname

:directory '(:absolute "foo" "bar")

:name "baz"

:type "txt") → #p"/foo/bar/baz.txt"

Однако, если вы желаете, чтобы ваши программы были переносимыми, то врядли вы пожелаете создавать файловые пути с нуля, даже если абстракция файловых путей предохраняет вас от непереносимого синтаксиса файловых имен, ведь файловые имена могут быть непереносимыми еще множеством способов. Например, файловое имя "/home/peter/foo.txt" не очень-то подходит для OS X, в которой /home/ представлено /Users/ .

Другой причиной, по которой не следует создавать файловые пути с нуля, является тот факт, что различные реализации используют компоненты файлового пути с небольшими различиями. Например, как было упомянуто выше, некоторые Windows-реализации LISP хранят букву диска в компоненте устройство в то время, как другие в компоненте хост. Если вы напишите:

(make-pathname :device "c" :directory '(:absolute "foo" "bar") :name "baz")

то это может быть правильным при использовании одной реализации, но не другой.

Вместо того, чтобы создавать пути с нуля, проще создать новый файловый путь, используя существующий файловый путь, при помощи аргумента-ключа :defaults функции MAKE-PATHNAME . С этим параметром можно предоставить указатель файлового пути, из которого будут взяты компоненты, не указанные другими аргументами. Для примера, следующее выражение создает файловый путь с расширением .html и компонентами из файлового пути input-file :

(make-pathname :type "html" :defaults input-file)

Предполагая, что значение input-file было предоставлено пользователем, этот код надёжен вопреки различиям операционных систем и реализаций, таким как наличие либо отсутствие в файловом пути буквы диска или место её расположения. [169]

Используя эту же технику, можно создать файловый путь с другой компонентой директория.

(make-pathname :directory '(:relative "backups") :defaults input-file)

Однако этот код создаст файловый путь с компонентой директория, равной относительному пути "backups/", безотносительно к любым другим компонентам файла input-file. Например:

(make-pathname :directory '(:relative "backups")

:defaults #p"/foo/bar/baz.txt") → #p"backups/baz.txt"

Возможно, когда-нибудь вы захотите объединить двое файловых путей, один из которых имеет относительный компонент директория), путем комбинирования их компонент директория. Например, предположим, что имеется относительный файловый путь #p"foo/bar.html" , который вы хотите объединить с абсолютным файловым путем #p"/www/html/" , чтобы получить #p"/www/html/foo/bar.html" . В этом случае MAKE-PATHNAME не подойдет; то, что вам надо, MERGE-PATHNAMES .

MERGE-PATHNAMES принимает два файловых пути и соединяет их, заполняя при этом компоненты, которые в первом файловом пути равны NIL , соответствующими значения из второго файлового пути. Это очень похоже на MAKE-PATHNAME , которая заполняет все неопределенные компоненты компонентами, предоставленные агрументом :defaults . Однако, MERGE-PATHNAMES особенно относится к компоненте директория: если директория первого файлового пути относительна, то директорией окончательного файлового пути будет директория первого пути отнсительно директории второго. Так:

(merge-pathnames #p"foo/bar.html" #p"/www/html/") → #p"/www/html/foo/bar.html"

Второй файловый путь также может быть относительным. В этом случае окончательный путь также будет относительным.

(merge-pathnames #p"foo/bar.html" #p"html/") → #p"html/foo/bar.html"

Для того, чтобы обратить это процесс, то есть получить файловый путь, который относителен определенной корневой директории, используйте полезную функцию ENOUGH-NAMESTRING .

(enough-namestring #p"/www/html/foo/bar.html" #p"/www/") → "html/foo/bar.html"

Вы можете соединить ENOUGH-NAMESTRING и MERGE-PATHNAMES для того, чтобы создать файловый путь, относительный другой корневой директории.

(merge-pathnames

(enough-namestring #p"/www/html/foo/bar/baz.html" #p"/www/")

#p"/www-backups/") → #p"/www-backups/html/foo/bar/baz.html"

MERGE-PATHNAMES используется стандартными функциями для доступа к файлам, чтобы дополнять незавершенные файловые пути. Например, пуcть есть файловый путь, имеющий только компоненты имя и тип.

(make-pathname :name "foo" :type "txt") → #p"foo.txt"

Если вы попытаетесь передать этот файловый путь как аргумент функции OPEN , недостающие компоненты, как, например, директория, должны быть заполнены, чтобы Lisp смог преобразовать файловый путь в действительное файловое имя. Common Lisp добудет эти значения для недостающих компонент, объединяя данный файловый путь со значением переменной *DEFAULT-PATHNAME-DEFAULTS* . Начальное значение этой переменной определенно реализацией, но, как правило, это файловый путь, компонент директория которого представляет ту директорию, в которой Lisp был запущен. Компоненты хост и устройство заполнены подходящеми значениями, если в этом есть необходимость. Если MERGE-PATHNAMES вызвана только с одним аргументом, то она объединит аргумент со значением *DEFAULT-PATHNAME-DEFAULTS* . Например, если *DEFAULT-PATHNAME-DEFAULTS* #p"/home/peter/" , то в результате:

(merge-pathnames #p"foo.txt") → #p"/home/peter/foo.txt"

<p>Два представления для имен директорий</p>

Существует один неприятный момент при работе с файловым путем, который представляет директорию. Файловые объекты разделяют компоненты директория и имя файла, но Unix и Windows рассматривают директории как еще один тип файла. Поэтому, в этих системах, каждая директория может иметь два различных преставления.

Одно из них, которое я назову представлением файла , рассматривает директорию, как любой другой файл и размещает последний элемент строки имени в компоненты имя и тип. Другое представление представление директории помещает все элементы имени в компонент директория, оставляя компоненты имя и тип равными NIL . Если /foo/bar/ директория, тогда любой из следующих двух файловых путей представляет ее.

(make-pathname :directory '(:absolute "foo") :name "bar") ; file form

(make-pathname :directory '(:absolute "foo" "bar")) ; directory form

Когда вы создаете файловые пути с помощью MAKE-PATHNAME , вы можете получить любое из двух представлений, но нужно быть осторожным, когда имеете дело со строками имен. Все современные реализации Lisp создают представление файла, если только строка имени не заканчивается разделителем пути. Но вы не можете полагаться на то, что строки имени, предоставленные пользователем, будут в том либо ином представлении. Например, предположим, что вы запросили у пользователя имя директории, в которой сохраните файл. Пользователь ввел "/home/peter" . Если передать функции MAKE-PATHNAME эту строку как аргумент :defaults :

(make-pathname :name "foo" :type "txt" :defaults user-supplied-name)

то в конце концов вы сохраните файл как /home/foo.txt , а не /home/peter/foo.text , как преполагалось, так как "peter" из строки имени будет помещен в компонент имя, когда user-supplied-name будет преобразовано в файловый путь. В переносимой библиотеке файловых путей, которую я обсужу в следующей главе, вы напишите функцию pathname-as-directory, которая преобразует файловый объект в представление директории. С этой функцией вы сможете сохранять наверняка файл в директории, указанной пользователем.

(make-pathname

:name "foo" :type "txt" :defaults (pathname-as-directory user-supplied-name))

<p>Взаимодействие с файловой системой</p>

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

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

Для того, чтобы проверить сущесвует ли файл, соответствующий указателю файлового пути будь-то файловый путь, строка имени или файловый поток, можно использовать функцию PROBE-FILE . Если файл, соответствующий указателю, существует, PROBE-FILE вернет "настоящее" имя файла файловый путь с любыми преобразованиями уровня файловой системой, как, например, следование по символическим ссылкам. В ином случае, она вернет NIL . Однока, не все реализации позволяют использовать ее для того, что проверить существует ли директория. Также, Common Lisp не предоставляет переносимого способа определить, чем является существующий файл обычным файлом или директорией. В следующей главе вы сделаете функцию-обертку для PROBE-FILE file-exists-p , которая может проверить существует ли файл и ответить: данное имя есть имя файла или директории.

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

DELETE-FILE и RENAME-FILE делают то, что следует из их названий. DELETE-FILE принимает указатель файлового пути и удаляет указанный файл, возвращая истину в случае успеха. В ином случае она сигнализирует FILE-ERROR . [170]

RENAME-FILE принимает два указателя файлового пути и изменяет имя файла, указанного первым параметром, на имя, указанное вторым параметром.

Вы можете создать директории с помощью функции ENSURE-DIRECTORIES-EXIST . Она принимает указатель файлового пути и удостоверяется, что все элементы компонента директория существуют и являются директориями, создавая их при необходимости. Так как она возвращает файловый путь, который ей был передан, то удобно ее вызывать на месте аргумента другой функции.

(with-open-file (out (ensure-directories-exist name) :direction :output)

...

)

Обратите внимание, что если вы передаете ENSURE-DIRECTORIES-EXIST имя директории, то оно должно быть в представлении директории, или последняя директория не будет создана. Обе функции FILE-WRITE-DATE и FILE-AUTHOR принимают указатель файлового пути. FILE-WRITE-DATE возвращает количество секунд, которое прошло с полуночи 1-го января 1900 года, среднее время по Гринвичу (GMT), до времени последней записи в файл. FILE-AUTHOR возвращает в Unix и Windows владельца файла. [171]

Для того, чтобы получить размер файла, используйте функцию FILE-LENGTH . По историческим причинам FILE-LENGTH принимает поток как аргумент, а не файловый путь. В теории это позволяет FILE-LENGTH вернуть размер в терминах типа элементов потока. Однако, так как на большинстве современных операционных системах единственная доступная информация о размере файла исключая чтение всего файла, чтобы узнать размер это размер в байтах, что возвращают большинство реализаций, даже если FILE-LENGTH передан символьный поток. Однако, стандарт не требует такого поведения, поэтому ради предсказуемых результатов лучший способ получить размер файла использовать бинарный поток. [172]

(with-open-file (in filename :element-type '(unsigned-byte 8))

(file-length in))

Похожая функция, которая также принимает открытый файловый поток в качестве аргумента, FILE-POSITION . Когда ей передают только поток, она возвращает текущую позицию в файле количество элементов, прочитанных из потока или записанного в него. Когда ее вызывают с двумя агрументами, поток и указателем позиции, она устанавливает текущей позицией указанную. Указатель позиции должен быть ключевым словом :start , :end или неотрицательное целое число. Два ключевых слова устанавливают позицию потока в начало или конец. Если же передать функции целое число, то позиция переместится в указанную позицию файла. В случае бинарного потока позиция это просто смещение в байтах от начала файла. Однако, символьные потоки немного сложнее из-за проблем с кодировками. Лучшее, что вы можете сделать, если нужно переместиться на другую позицию в текстовом файле, всегда передавать FILE-POSITION в качестве второго аргумента только значение, которое вернула функция FILE-POSITION , вызванная с тем же потоком в качестве единственного аргумента.

<p>Другие операции ввода/вывода</p>

Вдобавок к файловым потокам Common Lisp поддерживает другие типы потоков, которые также можно использовать с разнобразными функция ввода/вывода для чтения, записи и печати. Например, можно считывать данные из строки или записывать их в строку, используя STRING-STREAM , которое вы можете создать функциями MAKE-STRING-INPUT-STREAM и MAKE-STRING-OUTPUT-STREAM .

MAKE-STRING-INPUT-STREAM принимает строку и необязательные начальный и конечный индексы, указывающие часть строки, которую следует связать с потоком, и возвращает символьный поток, который можно передать как аргумент любой функции символьного ввода, как, например, READ-CHAR , READ-LINE или READ . Например, если у вас есть строка, содержащая число с плавающей точкой с синтаксисом Common Lisp, вы можете преобразовать ее в число с плавающей точкой:

(let ((s (make-string-input-stream "1.23")))

(unwind-protect (read s)

(close s)))

MAKE-STRING-OUTPUT-STREAM создает поток, который можно использовать с FORMAT , PRINT , WRITE-CHAR , WRITE-LINE и т.д. Она не принимает аргументов. Что бы вы не записывали, строковый потов вывода будет накапливать в строке, которую потом можно получить с помощью функции GET-OUTPUT-STREAM-STRING . Каждый раз при вызове GET-OUTPUT-STREAM-STRING внутренняя строка потока очищается, поэтому существующий строковый поток вывода можно снова использовать.

Однако, использовать эти функции напрямую вы будете редко, так как макросы WITH-INPUT-FROM-STRING и WITH-OUTPUT-TO-STRING предоставляют более удобный интерфейс. WITH-INPUT-FROM-STRING похожа на WITH-OPEN-FILE она создает строковый поток ввода на основе переданной строки и выполняет код в своем теле с потоком, который присвоен переменной, вами предоставленной. Например, вместо формы LET с явным использованием UNWIND-PROTECT , вероятно, лучше написать:

(with-input-from-string (s "1.23")

(read s))

Макрос WITH-OUTPUT-TO-STRING также связывает вновь созданный строковый поток вывода с переменной, вами названной, и затем выполняет код в своем теле. После того, как код был выполнен, WITH-OUTPUT-TO-STRING вернет значение, которое было бы возвращено GET-OUTPUT-STREAM-STRING .

CL-USER> (with-output-to-string (out)

(format out "hello, world ")

(format out "~s" (list 1 2 3)))

"hello, world (1 2 3)"

Другие типы потоков, определенные в стандарте языка, предоставляют различные способы "соединения" потоков, то есть позволяют подключать потоки друг к другу почти в любой конфигурации. BROADCAST-STREAM поток вывода, который посылает записанные данные множеству потоков вывода, переданного как аргумент функции-конструктуру, MAKE-BROADCAST-STREAM . [173] В противоположность этому, CONCATENATED-STREAM поток ввода, который принимает ввод от множества потоков ввода, перемещаясь от потока к потоку, когда очередной поток достигает конца. Потоки CONCATENATED-STREAM создаются функцией MAKE-CONCATENATED-STREAM , которая принимает любое количество потоков ввода в качестве аргументов.

Еще существуют два вида двунаправленных потоков, которые могут подключать потоки друг к другу TWO-WAY-STREAM и ECHO-STREAM . Их функции-конструкторы, MAKE-TWO-WAY-STREAM и MAKE-ECHO-STREAM , обе принимают два аргумента, поток ввода и поток вывода, и возвращают поток соответствующего типа, который можно использовать как с потоками ввода, так и с потоками вывода.

В случае TWO-WAY-STREAM потока, каждое чтение вернет данные из потока ввода, и каждая запись пошлет данные в поток вывода. ECHO-STREAM по существу работает точно так же кроме того, что все данные прочитанные из потока ввода также направляются в поток вывода. То есть поток вывода потока ECHO-STREAM будет содержать стенограмму "беседы" двух потоков.

Используя эти пять типов потоков, можно построить почти любою топологию сети потоков, какую бы вы ни пожелали.

В заключение, хотя стандарт Common Lisp не оговаривает какой-либо сетевой API, большинство реализаций поддерживают программирование сокетов и, как правило, реализуют сокеты как еще один тип потока. Следовательно, можно использовать с ними все обычные функции вводы/вывода. [174]

Теперь вы готовы двигаться дальше для написания библиотеки, которая позволит сгладить некоторые различия поведения основных функций для работы с файловыми путями в различных реализациях Common Lisp.

15
<p>15. Практика: переносимая библиотека файловых путей</p>

Как было сказано в предыдущей главе, Common Lisp предоставляет абстракцию файловых путей, и предполагается, что она изолирует вас от деталей того, как различные операционные и файловые системы именуют файлы. Файловые пути предоставляют удобное API для управления именами самими по себе, но когда дело доходит до функций, которые на самом деле взаимодействуют с файловой системой, всё не так гладко.

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

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

<p>API</p>

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

Теоретически, эти операции просмотра директории и проверки существования файла уже предоставлены стандартными функциями DIRECTORY и PROBE-FILE . Однако, вы увидите, что есть несколько разных путей для реализации этих функций все в рамках правильных интерпретаций стандарта языка и вам захочется написать новые функции, которые предоставят единообразное поведение для разных реализаций.

<p>Переменная *FEATURES* и обработка условий при считывании.</p>

Перед тем, как реализовать API в библиотеке, которая будет корректно работать на нескольких реализациях Common Lisp, мне нужно показать вам механизм для написания кода, предназначенного для определённой реализации.

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

Этот механизм состоит из переменной *FEATURES* и двух дополнительных частей синтаксиса, понимаемых считывателем Lisp. *FEATURES* является списком символов; каждый символ представляет собой «свойство», которое присутствует в реализации или используемой ей платформе. Эти символы затем используются в выражениях на свойства, которые вычисляются как истина или ложь, в зависимости о того, присутствуют ли символы из этих выражений в переменной *FEATURES* . Простейшее выражение на свойство — одиночный символ; это выражение истинно, если символ входит в *FEATURES* , и ложно в противном случае. Другие выражения на свойства — логические выражения, построенные из операторов NOT , AND или OR . Например, если бы вы захотели установить условие, чтобы некоторый код был включен только если присутствуют свойства foo и bar, вы могли бы записать выражение на свойства (and foo bar) .

Считыватель использует выражения на свойства вместе с двумя элементами синтаксиса, #+ и #- . Когда считыватель видит один из них, он сначала читает выражение на свойство и затем вычисляет его, как я только что описал. Когда выражение на свойство, следующее за #+ , является истинным, считыватель считывает следующее выражение обычным образом. В противном случае он пропускает следующее выражение, считая его пробелом. #- работает также, за исключением того, что форма считывается, если выражение на свойство ложно, и пропускается, если оно истинно.

Начальное значение *FEATURES* зависит от реализации, и функциональность, подразумеваемая любым присутствующим в ней символом, тоже определяется реализацией. Однако, все реализации включают по крайней мере один символ, указывающий на неё саму. Например, Allegro Common Lisp включает символ :allegro , CLISP включает :clisp , SBCL включает :sbcl и CMUCL включает :cmu . Чтобы избежать зависимостей от пакетов, которые могут или не могут существовать в различных реализациях, символы в *FEATURES* - обычно ключевые слова, и считыватель связывает *PACKAGE* c пакетом KEYWORD во время считывания выражений. Таким образом, имя без указания пакета будем прочитано как ключевой символ. Итак, вы могли бы написать функцию, которая ведёт себя немного по-разному в каждой из только что упомянутых реализаций так:

(defun foo ()

#+allegro (do-one-thing)

#+sbcl (do-another-thing)

#+clisp (something-else)

#+cmu (yet-another-version)

#-(or allegro sbcl clisp cmu) (error "Not implemented"))

В Allegro этот код будет считан, как если бы он был написан так:

(defun foo ()

(do-one-thing))

тогда как в SBCL считыватель прочитает это:

(defun foo ()

(do-another-thing))

а в реализации, отличной от тех, на которые специально установлены условия, будет считано следующее:

(defun foo ()

(error "Not implemented"))

Так как обработка условий происходит в считывателе, компилятор даже не увидит пропущенные выражения [175]. Это означает, что вы не тратите время выполнения на наличие различных версий для разных реализаций. Также, когда считыватель пропускает выражения, на которые установлены условия, его не волнуют входящие символы, так что пропущенные выражения могут спокойно содержать символы из пакетов, которые не существуют в других реализациях.

<p>Создание пакета библиотеки</p>

Кстати о пакетах, если вы загрузите полный код этой библиотеки, то увидите, что она определена в новом пакете, com.gigamonkeys.pathnames . Я расскажу о деталях определения и использования пакетов в главе 21. Сейчас вы должны отметить, что некоторые реализации предоставляют свои пакеты, которые содержат функции с некоторыми такими же именами, как вы определите в этой главе, и делают эти имена доступными из пакета CL-USER . Таким образом, если вы попытаетесь определить функции этой библиотеки, находясь в пакете CL-USER , вы можете получить сообщения об ошибках о конфликтах с существующими определениями. Чтобы избежать этой возможности, вы можете создать файл с названием packages.lisp и следующим содержанием:

(in-package :cl-user)

(defpackage :com.gigamonkeys.pathnames

(:use :common-lisp)

(:export

:list-directory

:file-exists-p

:directory-pathname-p

:file-pathname-p

:pathname-as-directory

:pathname-as-file

:walk-directory

:directory-p

:file-p))

и сделать LOAD на него. Тогда в REPL или в начале файла, в который вы печатаете определения из этой главы, напечатайте следующее выражение:

(in-package :com.gigamonkeys.pathnames)

В дополнение к избежанию конфликтов имён с символами, уже доступными в CL-USER , создание пакета для библиотеки таким образом также сделает проще использовать её в другом коде, как вы увидите из нескольких будущих глав.

<p>Получение списка файлов в директории</p>

Вы можете реализовать функцию для получения списка файлов одной директории, list-directory , как тонкую обёртку вокруг стандартной функции DIRECTORY . DIRECTORY принимает особый тип файлового пути, называемого шаблоном файлового пути , который имеет одну или более компоненту, содержащую специальное значение :wild , и возвращает список файловых путей, представляющих файлы в файловой системе, которые соответствуют шаблону [176]. Алгоритм сопоставления — как большинство вещей, которым приходится иметь дело с взаимодействием между Lisp и конкретной файловой системой — не определяется стандартом языка, но большинство реализаций на Unix и Windows следуют одной и той же базовой схеме.

Функция DIRECTORY имеет две проблемы, с которыми придётся иметь дело list-directory . Главная проблема состоит в том, что определённые аспекты поведения этой функции различаются достаточно сильно для различных реализаций Common Lisp, даже для одной и той же операционной системы. Другая проблема в том, что, хотя DIRECTORY и предоставляет мощный интерфейс для получения списка файлов, её правильное использование требует понимания некоторых достаточно тонких моментов в абстракции файловых путей. С этими тонкостями и стилевыми особенностями различных реализаций, само написание переносимого кода, использующего DIRECTORY для таких простых вещей, как получение списка всех файлов и поддиректорий для единственной директории, могло бы стать разочаровывающим опытом. Вы можете разобраться со всеми тонкостями и характерными особенностями раз и навсегда, написав list-directory и забыв о них после этого.

Одна тонкость, которая обсуждалась в главе 14 — это два способа представлять имя директории в виде файлового пути: в форме директории и в форме файла.

Чтобы DIRECTORY возвратила вам список файлов в /home/peter/ , вам надо передать ей шаблон файлового пути, чья компонента директории — это директория, которую вы хотите прочитать, и чьи компоненты имени и типа являются :wild . Таким образом, может показаться, что для получения списка файлов в /home/peter/ вы можете написать это:

(directory (make-pathname :name :wild :type :wild :defaults home-dir))

где home-dir является файловым путём, представляющим /home/peter/ . Это бы сработало, если бы home-dir была бы в форме директории. Но если бы она была бы в файловой форме — например, если бы она была создана разбором строки "/home/peter" - тогда бы это выражение вернуло список всех файлов в /home, так как компонента имени "peter" была бы заменена на :wild .

Чтобы избежать беспокойства о явном преобразовании между представлениями, вы можете определить list-directory так, чтобы она принимала нешаблонный файловый путь в обоих формах, который затем она будет переводить в подходящий шаблон файлового пути.

Чтобы облегчить это, вам следует определить несколько вспомогательных функций. Одна, component-present-p , будет проверять, «существует» ли данная компонента в файловом пути, имея в виду не NIL и не специальное значение :unspecific . [177]. Другая, directory-pathname-p , проверяет, задан ли файловый путь уже в форме директории, и третья, pathname-as-directory , преобразует любой файловый путь в файловый путь в форме директории.

(defun component-present-p (value)

(and value (not (eql value :unspecific))))

(defun directory-pathname-p (p)

(and

(not (component-present-p (pathname-name p)))

(not (component-present-p (pathname-type p)))

p))

(defun pathname-as-directory (name)

(let ((pathname (pathname name)))

(when (wild-pathname-p pathname)

(error "Can't reliably convert wild pathnames."))

(if (not (directory-pathname-p name))

(make-pathname

:directory (append (or (pathname-directory pathname) (list :relative))

(list (file-namestring pathname)))

:name nil

:type nil

:defaults pathname)

pathname)))

Теперь кажется, что можно создать шаблон файлового путь для передачи DIRECTORY , вызвав MAKE-PATHNAME с формой директории, возвращённой pathname-as-directory . К несчастью, благодаря одной причуде в реализации DIRECTORY в CLISP, всё не так просто. В CLISP, DIRECTORY вернёт файлы без расширений, только если компонента типа шаблона является NIL , но не :wild . Так что вы можете определить функцию, directory-wildcard , которая принимает файловый путь в форме директории или файла, и возвращает шаблон, подходящий для данной реализации, используя проверку условий при считывании для того, чтобы делать файловый путь с компонентой типа :wild во всех реализациях, за исключением CLISP, и NIL в CLISP.

(defun directory-wildcard (dirname)

(make-pathname

:name :wild

:type #-clisp :wild #+clisp nil

:defaults (pathname-as-directory dirname)))

Заметьте, что каждое условие при считывании работает на уровне единственного выражения после #-clisp , выражение :wild будет или считано, или пропущено; ровно как и после #+clisp , NIL будет прочитано или пропущено.

Теперь вы можете первый раз вгрызться в функцию list-directory .

(defun list-directory (dirname)

(when (wild-pathname-p dirname)

(error "Can only list concrete directory names."))

(directory (directory-wildcard dirname)))

Утверждается, что эта функция будет работать в SBCL, CMUCL и LispWorks. К несчастью, остаётся парочка различий, которые надо сгладить. Одно отличие состоит в том, что не все реализации вернут поддиректории данной директории. Allegro, SBCL, CMUCL и LispWorks сделают это. OpenMCL не делает это по умолчанию, но сделает, если вы передадите DIRECTORY истинное значение по специфичному для этой реализации ключевому аргументу :directories . DIRECTORY в CLISP возвращает поддиректории только когда ей передаётся шаблон файлового пути с :wild в последнем элементе компоненты директории и NIL в компонентах имени и типа. В этом случае, он вернёт только поддиректории, так что вам придётся вызвать DIRECTORY дважды с разными шаблонами и скомбинировать результаты.

Как только вы заставите все реализации возвращать директории, вы узнаете, что они также различаются в том, возвращают ли они имена директорий в форме директорий или файлов. Вы хотите, чтобы list-directory всегда возвращала имена директорий в форме директорий, так, чтобы вы могли отличать поддиректории от обычных файлов, основываясь просто на имени. За исключением Allegro, все реализации этой библиотеки поддерживают это. Allegro, c другой стороны, требует передачи DIRECTORY характерного для этой реализации аргумента :directories-are-files со значением NIL , чтобы заставить её возвратить директории в форме файлов.

Как только вы узнали о том, как сделать так, чтобы каждая реализация делала то, что вы хотите, само написание list-directory становится просто делом сочетания различных версий при помощи проверки условий при чтении.

(defun list-directory (dirname)

(when (wild-pathname-p dirname)

(error "Can only list concrete directory names."))

(let ((wildcard (directory-wildcard dirname)))

#+(or sbcl cmu lispworks)

(directory wildcard)

#+openmcl

(directory wildcard :directories t)

#+allegro

(directory wildcard :directories-are-files nil)

#+clisp

(nconc

(directory wildcard)

(directory (clisp-subdirectories-wildcard wildcard)))

#-(or sbcl cmu lispworks openmcl allegro clisp)

(error "list-directory not implemented")))

Функция clisp-subdirectories-wildcard на самом деле не является присущей CLISP, но так как она не нужна никакой другой реализации, вы можете ограничить её условием при чтении. В этом случае, так как выражение, следующее за #+ является целым DEFUN , будет или не будет включено всё определение функции, в зависимости от того, присутствует ли clisp в *FEATURES* .

#+clisp

(defun clisp-subdirectories-wildcard (wildcard)

(make-pathname

:directory (append (pathname-directory wildcard) (list :wild))

:name nil

:type nil

:defaults wildcard))

<p>Проверка существования файла</p>

Чтобы заменить PROBE-FILE , вы можете определить функцию с именем file-exists-p . Она должна принимать имя файла и, если файл существует, возвращать то же самое имя, и NIL , если не существует. Она должна быть способна принимать имя директории и в виде директории, и в виде файла, но должна всегда возвращать файловый путь в форме директории, если файл существует и является директорией. Это позволит вам использовать file-exists-p вместе с directory-pathname-p , чтобы проверить, является ли данное имя именем файла или директории.

Теоретически, file-exists-p достаточно похожа на стандартную функцию PROBE-FILE , и на самом деле, в нескольких реализациях — SBCL, LispWorks, OpenMCL – PROBE-FILE уже даёт вам то поведение, которого вы хотите от file-exists-p . Но не все реализации PROBE-FILE ведут себя так.

Функции PROBE-FILE в Allegro и CMUCL близки к тому, чего вы хотите — они принимают имя директории в обоих формах, но, вместо возвращения имени в форме директории, просто возвращают его в той же самой форме, в которой им был передан аргумент. К счастью, если им передаётся имя недиректории в форме директории, они возвращают NIL . Так что, в этих реализациях вы можете получить желаемое поведение, сначала передав PROBE-FILE имя в форме директории — если файл существует и является директорией, она возвратит имя в форме директории. Если этот вызов вернёт NIL , вы попытаетесь снова с именем в форме файла.

CLISP, с другой стороны, снова имеет свой собственный способ сделать это. Его PROBE-FILE немедленно сигнализирует ошибку, если передано имя в форме директории, вне зависимости от того, существует ли файл или директория с таким именем. Она также сигнализирует ошибку, если в файловой форме передано имя, которое на самом деле является именем директории. Для определения, существует ли директория, CLISP предоставляет собственную функцию: probe-directory (в пакете ext ). Она практически является зеркальным отражением PROBE-FILE : выдаёт ошибку, если ей передаётся имя в файловой форме или если передано имя в форме директории, которое оказалось именем файла. Единственное различие в том, что она возвращает T , а не файловый путь, когда существует названная директория.

Но даже в CLISP вы можете реализовать желаемую семантику, обернув вызовы PROBE-FILE и probe-directory в IGNORE-ERRORS [178].

(defun file-exists-p (pathname)

#+(or sbcl lispworks openmcl)

(probe-file pathname)

#+(or allegro cmu)

(or (probe-file (pathname-as-directory pathname))

(probe-file pathname))

#+clisp

(or (ignore-errors

(probe-file (pathname-as-file pathname)))

(ignore-errors

(let ((directory-form (pathname-as-directory pathname)))

(when (ext:probe-directory directory-form)

directory-form))))

#-(or sbcl cmu lispworks openmcl allegro clisp)

(error "file-exists-p not implemented"))

Функция pathname-as-file , которая нужна вам для реализации file-exists-p в CLISP является обратной для определённой ранее pathname-as-directory , возвращающей файловый путь, являющийся эквивалентом аргумента в файловой форме. Несмотря на то, что эта функция нужна здесь только для CLISP, она полезна в общем случае, так что определим её для всех реализаций и сделаем частью библиотеки.

(defun pathname-as-file (name)

(let ((pathname (pathname name)))

(when (wild-pathname-p pathname)

(error "Can't reliably convert wild pathnames."))

(if (directory-pathname-p name)

(let* ((directory (pathname-directory pathname))

(name-and-type (pathname (first (last directory)))))

(make-pathname

:directory (butlast directory)

:name (pathname-name name-and-type)

:type (pathname-type name-and-type)

:defaults pathname))

pathname)))

<p>Проход по дереву директорий</p>

Наконец, чтобы завершить библиотеку, вы можете реализовать функцию, называемую walk-directory . В отличие от ранее определённых функций, эта функция не нужна для сглаживания различий между реализациями; она просто использует функции, которые вы уже определили. Однако, она довольно удобна, и вы будете её несколько раз использовать в последующих частях. Она будет принимать имя директории и функцию, и вызывать функцию на всех файлах входящих в директорию рекурсивно. Она также принимает два ключевых аргумента: :directories и :test . Когда :directories истинно, она будет вызывать функцию на именах директорий, как на обычных файлах. Аргумент :test , если предоставлен, определяет другую функцию, которая вызывается на каждом файловом пути до того, как будет вызвана главная функция, которая будет вызвана только если тестовая функция возвратит истинное значение.

(defun walk-directory (dirname fn &key directories (test (constantly t)))

(labels

((walk (name)

(cond

((directory-pathname-p name)

(when (and directories (funcall test name))

(funcall fn name))

(dolist (x (list-directory name)) (walk x)))

((funcall test name) (funcall fn name)))))

(walk (pathname-as-directory dirname))))

Теперь у вас есть полезная библиотека функций для работы с файловыми путями. Как я упомянул, эти функции окажутся полезны в следующих частях, особенно в частях 23 и 27, где вы будете использовать walk-directory , чтобы продраться через дерево директорий, содержащих спамерские сообщения и MP3 файлы. Но до того как мы доберёмся до этого, мне, тем не менее, нужно поговорить о объектной ориентации, теме следующих двух глав.

16
<p>16. Переходим к объектам: Обобщенные функции</p>

Поскольку Lisp был создан за пару десятилетий до того момента, когда объектно-ориентированное программирование (ООП) стало популярным [179], начинающие Lisp-программисты иногда удивляются, открывая для себя, насколько полноценным объектно-ориентированным языком является Common Lisp. Непосредственные его предшественники разрабатывались в то время, когда объектно-ориентированное программирование было волнующе-новой парадигмой и проводилось много экспериментов на тему включения его идей (особенно из языка Smalltalk) в Lisp. Как часть процесса стандартизации Common Lisp, объединение идей нескольких этих экспериментов было представлено под названием Common Lisp Object System (Объектная Система Common Lisp) или CLOS [180]. Стандарт ANSI включил CLOS в язык, так что сейчас нет смысла говорить о CLOS как об отдельной сущности.

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

В начале вы должны были заметить, что объектная система Common Lisp предлагает достаточно отличающуюся от других языков реализацию принципов ООП. Если вы имеете глубокое понимание фундаментальных идей, заложенных в основу ООП, то вы, вероятно, оцените способ, который использовался в Common Lisp для их реализации. С другой стороны, если у вас есть опыт использования ООП только на одном языке, то подход Common Lisp может показаться вам несколько чуждым; вы должны избегать предположений, что для языка существует только один способ реализации принципов ООП [181]. Если у вас имеется небольшой опыт объектно-ориентированного программирования, то вы не должны испытывать проблем с пониманием изложенных здесь объяснений, хотя, в этом случае, возможно, будет лучше проигнорировать сравнения с подходами других языков.

<p>Обобщенные функции и классы</p>

Мощной и фундаментальной особенностью ООП является способ организации программ путем определения типов данных и связывании операций с ними. В частности, вам может понадобиться возможность исполнять операцию, получая поведение, определенное типом объекта (или объектов) для которых эта операция выполняется. Классическим примером, представленным во всех введениях в ООП, является операция рисования, применимая к объектам, представляющим различные геометрические фигуры. Для рисования окружностей, треугольников или квадратов могут быть реализованы разные методы draw, которые будут отображать окружность, треугольник или квадрат, в зависимости от объекта, к которому применяется операция рисования. Эти реализации вводятся раздельно, и новые версии для рисования других фигур могут быть определены, не затрагивая ни кода базового класса, ни других draw. Эта возможность ООП имеет красивое греческое имя "полиморфизм (polymorphism)" , переводимое как "множество форм", поскольку одна концептуальная операция, такая как рисование объекта, может иметь множество различных конкретных форм.

Common Lisp, подобно другим современным объектно-ориентированным языкам, основан на классах; все объекты являются экземплярами определенного класса [182]. Класс объекта определяет его представление встроенные классы, такие как NUMBER и STRING имеют скрытое представление, доступное только через стандартные функции для работы с этими типами, в то время как экземпляры классов, определенных пользователем, состоят из именованных частей, называемых слотами (вы увидите это в следующей главе).

Классы образуют иерархию/классификацию всех объектов. Класс может быть определен как подкласс других классов, называемых базовыми (или суперклассами). Класс наследует от суперклассов часть своего определения, а экземпляры класса также считаются и экземплярами суперклассов. В Common Lisp иерархия классов имеет один корень класс T , который является прямым (или косвенным) суперклассом для всех остальных классов. Таким образом, в Common Lisp все данные являются экземплярами класса T [183]. Common Lisp также поддерживает множественное наследование один класс может иметь несколько прямых суперклассов.

Вне семейства языков Lisp, почти все объектно-ориентированные языки следуют базовому дизайну, заданному языком Simula, когда поведение, связанное с классом, реализуется в виде методов или функций-членов, которые относятся к определенному классу. В этих языках метод, вызываемый для определенного объекта, и класс, к которому этот объект относится, определяют, какой код будет запущен. Такая модель называется (в терминологии Smalltalk) передачей сообщений (message-passing). Концептуально, вызов методов начинается с отправки сообщения, содержащего имя запускаемого метода и необходимые аргументы, экземпляру объекта, метод которого вызывается. Объект затем использует свой класс для поиска метода, связанного с именем, указанным в сообщении, и вызывает его. Поскольку каждый класс может иметь собственный метод для заданного имени, то одно и то же сообщение, посланное разным объектам, может вызывать разные методы.

Ранние реализации объектной системы Lisp работали аналогичным образом, предоставляя специальную функцию SEND , которая могла быть использована для отправки сообщения определенному объекту. Однако, это было не совсем удобно, поскольку это делало вызов метода отличным от обычного вызова функции. Синтаксически вызов метода записывался как:

(send object 'foo)

вместо:

(foo object)

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

(mapcar #'(lambda (object) (send object 'foo)) objects)

вместо:

(mapcar #'foo objects)

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

Обобщенные функции суть сердце объектной системы Common Lisp и тема оставшейся части данной главы. Поскольку я не могу говорить об обобщенных функциях без упоминания классов, то сначала я остановлюсь на том, как определить и использовать обобщенные функции. А в следующей главе я покажу вам как определять ваши собственные классы.

<p>Обобщенные функции и методы</p>

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

(defgeneric draw (shape)

(:documentation "Draw the given shape on the screen."))

Я опишу синтаксис DEFGENERIC в следующем разделе; сейчас лишь замечу, что это определение совсем не содержит кода.

Обобщенная функция являются таковой в том смысле, что она может (по крайней мере в теории) принимать в качестве аргументов любые объекты [184]. Однако, сама эта функция не делает ничего; если вы просто определили её, то при вызове с любыми аргументами она будет выдавать ошибку. Действующая реализация обобщенной функции обеспечивается методами. Каждый метод предоставляет реализацию обобщенной функции для отдельных классов аргументов. Вероятно, наибольшим отличием между системами с обобщенными функциями и системами с передачей сообщений является то, что методы не не принадлежат к классам; они относятся к обобщенной функции, которая ответственна за определение того, какой метод (или методы) будет исполняться в ответ на конкретный вызов.

Методы указывают какой вид аргументов они могут обрабатывать, путем специализации требуемых параметров, определенных обобщенной функцией. Например, для обобщенной функции draw , вы можете определить один метод, который определяет специализацию параметра shape для объектов, которые являются экземплярами класса circle , в то время как другой метод специализирует shape для экземпляров класса triangle . Они могут выглядеть следующим образом (не вдаваясь в подробности рисования конкретных фигур):

(defmethod draw ((shape circle))

...)

(defmethod draw ((shape triangle))

...)

При вызове обобщенной функции, она сравнивает переданные аргументы со специализаторами каждого из ее методов с целью найти среди них апплицируемые чьи специализаторы совместимы с фактическими параметрами (вызова). Если вы вызываете draw , передавая экземпляр circle , то применяется метод, которые специализирует shape для класса circle , а если вы вызываете передавая triangle , то будет вызван метод, который специализирует shape для triangle . В простых случаях, будет подходить только один метод, который и будет обрабатывать вызов. В более сложных случаях, могут быть применимы несколько методов; они будут скомбинированы, как я опишу в разделе "Комбинация методов", в один действующий метод, который обработает данный вызов.

Вы можете специализировать параметры двумя способами обычно вы указываете класс, экземпляром которого должен быть аргумент. Поскольку экземпляры класа также рассматриваются как экземпляры его суперклассов, то метод специализированный для конкретного класса, также применим для аргументов, которые могут быть как экземлярами специализирующего класса, так и его подклассов. Другой вид специализации так называемый EQL -специализатор, который определяет конкретный объект к которому применим данный метод.

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

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

<p>DEFGENERIC</p>

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

Поскольку я буду обсуждать вопросы создания новых классов только в следующей главе, сейчас вы можете просто предположить, что определенные классы уже существуют: для начала предположим, имеется класс bank-account и он имеет два подкласса checking-account и savings-account . Иерархия классов выглядит следующим образом: :pcl:account-hierarchy_1_.png

Первой обобщенной функцией будет withdraw , которая уменьшает баланс на указанную сумму. Если баланс меньше этой суммы, то она должна выдать ошибку и оставить баланс в неизменном виде. Вы можете начать с определения обобщенной функции при помощи DEFGENERIC .

Основная форма DEFGENERIC похожа DEFUN за тем исключением, что нет тела функции. Список параметров DEFGENERIC определяет параметры, которые должны приниматься всеми методами, определенными для данной обобщенной функции. Вместо тела DEFGENERIC может содержать различные опции. Одной из опций, которую вы должны всегда указывать, является :documentation , которая используется для указания строки с описанием назначения обобщенной функции. Поскольку обобщенная функция является полностью абстрактной, важно, чтобы и пользователь и программист имели четкое представление о том, что она делает. Таким образом, вы можете определить withdraw следующим образом:

(defgeneric withdraw (account amount)

(:documentation "Withdraw the specified amount from the account.

Signal an error if the current balance is less than amount."))

<p>DEFMETHOD</p>

Сейчас вы готовы к использованию DEFMETHOD для определения методов, которые реализуют withdraw [185].

Список параметров метода должен быть конгруэнтен его обобщенной функции. В данном случае это означает, что все методы определенные для withdraw должны иметь два обязательных параметра. В более общих чертах, методы должны иметь то же самое количество обязательных и необязательных параметров, и кроме этого, должны уметь принимать любые аргументы, относящиеся к остаточным ( &rest ) или именованным ( &key ) параметрам, определенным в обобщенной функции. [186]

Поскольку базовые действия по списанию денег со счета являются одинаковыми для всех счетов, то вы можете определить метод, который специализирует параметр account для класса bank-account . Вы можете предположить, что функция balance возвращает текущее значение суммы на счете и может быть использована вместе с функцией SETF (и таким образом, вместе с DECF ) для установки значения баланса. Функция ERROR является стандартной функцией для сообщения об ошибках и я ее подробно опишу в главе 19. Используя эти две функции, вы можете определить базовыйFIXME простой метод withdraw примерно так:

(defmethod withdraw ((account bank-account) amount)

(when (< (balance account) amount)

(error "Account overdrawn."))

(decf (balance account) amount))

Как видно из этого кода, форма DEFMETHOD более похожа на DEFUN по сравнению с DEFGENERIC . Основным отличием является то, что требуемые параметры могут быть специализированы путем замены имени параметра на список из двух элементов. Первым элементом является имя параметра, а вторым специализатор, который может быть либо именем класса, либо EQL -специализатором, который будет обсуждаться немного позже FIXME который я опишу чуть позже . Имя параметра может быть любым оно не обязательно должно совпадать с именем, указанным в объявлении обобщенной функции, хотя это часто используетсяFIXME хотя часто будет .

Этот метод будет использоваться тогда, когда первый аргумент withdraw является экземпляром класса bank-account . Второй параметр, amount , неявно специализируется для класса T , а поскольку все объекты являются экземплярами T , это никак не затрагивает применимость метода.

Тепер предположим, что все чековые счета имеют защиту от перерасхода. Так что, каждый чековый счет связан с другим счетом в банке, с которого будет производиться списание, когда на чековом счету недостаточно денег для списания. Вы можете предположить, что функция overdraft-account получает объект класса checking-account и возвращает объект класса bank-account , представляющего собой связанный счет.

Таким образом, списание с объекта класса checking-account требует выполнения дополнительных шагов по сравнению со списанием с обычного счетаFIXME списанием со стандартного объекта bank-account . Вы сначала должны проверить, является ли списываемая сумма большей, чем имеющаяся на счету, и если это так, то перенести недостающую сумму со связанного счета. Затем,DELETEME (запятая) вы можете продолжать вычисления также, как и для обычно счетаFIXME как и со стандартным объектом bank-account .

Так что вы можете захотеть определить метод withdraw , который специализирован для checking-account для обработки перевода денег с другого счета, а затем передать управление методу, специализированному для обычных счетов (класса bank-account ) FIXME счета и последующей передачи управление методу, специализированному на bank-account . Такой метод может выглядеть вот так:

(defmethod withdraw ((account checking-account) amount)

(let ((overdraft (- amount (balance account))))

(when (plusp overdraft)

(withdraw (overdraft-account account) overdraft)

(incf (balance account) overdraft)))

(call-next-method))

Функция CALL-NEXT-METHOD является частью системы обобщенных функций и используется для комбинации FIXME подходящих методов. Она сообщает, что контроль должен быть передан от текущего метода, к методу, специализированному для bank-account . [187] Когда он вызывается без аргументов, как это было сделано в нашем примере, следующий в цепочке метод будет вызван с теми же аргументами, которые были переданы обобщенной функции. Он также может быть вызван с явным указанием аргументов, которые будут переданы следующему методу.

Вам не обязательно вызывать CALL-NEXT-METHOD в каждом методе. Однако, если вы не будете вызывать эту функцию, то новый метод будет полностью отвечать за реализацию требуемого поведения обобщенной функции. Например, если вы хотите создать подкласс bank-account , названный proxy-account , который не будет отслеживать свой баланс, а вместо этого будет делегировать списание средств другому счету, то вы можете записать этот метод следующим образом (предполагая, что функция proxied-account возвращает соответствующий счет):

(defmethod withdraw ((proxy proxy-account) amount)

(withdraw (proxied-account proxy) amount))

В заключение, DEFMETHOD также позволяет вам создавать методы, которые специализированы для конкретного объекта используя EQL -специализатор. Например, предположим, что банковское приложение FIXME будет развернуто в каком-то коррумпированном банке. Предположим, что переменная *account-of-bank-president* хранит ссылку на конкретный банковский счет, который относится (как это видно из имени) к президенту банка. ТакжеFIXME Далее предположим, что переменная *bank* представляет весь банк, а функция embezzle крадет деньги у банка. Президент банка может попросить вас "исправить" функцию withdraw таким образом, чтобы она обрабатывала его счет другим способом.

(defmethod withdraw ((account (eql *account-of-bank-president*)) amount)

(let ((overdraft (- amount (balance account))))

(when (plusp overdraft)

(incf (balance account) (embezzle *bank* overdraft)))

(call-next-method)))

Однако заметьте, что форма, указанная в EQL -специализаторе, который используется для указания объекта (в нашем случае это *account-of-bank-president* ) вычисляется один раз, когда вычисляется DEFMETHOD . Этот метод будет специализирован для значения *account-of-bank-president* в тот момент, когда этот метод был определен; последующие изменения переменной не изменяют метод.

<p>Комбинирование методов</p>

Вне кода метода использование CALL-NEXT-METHOD не имеет смысла. Внутри метода использование этой функции обретает смысл за счет того, что механизмы реализации обобщенных функций при каждом запуске создают эффективный метод из методов, для которых применимы текущие параметры. Идея построения эффективного метода путем комбинации методов, для которых применимы текущие аргументы, является основой концепции обобщенных функций и это позволяет обобщенным функциям реализовывать возможности, которые недоступны в системах с передачей сообщений. Так что следует поближе познакомиться с тем, что в действительности происходит. Люди, которые давно знакомы с концепциями систем с передачей сообщений должны обратить на данный материал особое внимание, поскольку обобщенные функции кардинально меняют диспатчеризацию методов по сравнению с передачей сообщений, делая обобщенную функцию (а не класс) ответственной за передачу управления.

В соответствии с концепцией, эффективный метод строится в три шага: на первом шаге обобщенная функция строит список методов, для которых применимы переданные аргументы. На втором шаге полученный список методов сортируется в соответствии со специализированными параметрами. И в заключение, методы по порядку берутся из списка и их код комбинируется образуя эффективный метод. [188]

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

Когда специализатор является именем класса, он считается совместимым, если указанное имя совпадает с именем класса аргумента (или именем одного из суперклассов аргумента). (Заметьте, что параметры без явных специализаторов, неявно специализируются классом T , так что они будут совместимы с любым аргументом). EQL -специализатор считается совместимым, если аргумент является тем же объектом, что указан в специализаторе.

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

После того, как все соответствующие методы найдены, необходимо отсортировать их, чтобы затем скомбинировать в эффективный метод. Для упорядочения двух методов, обобщенная функция сравнивает их специализаторы параметров слева направо, [189] и первый специализатор, который отличается в списке параметров методов будет определять их порядок, где первым ставится метод с более специфичным специализатором.

Поскольку сортируются только подходящие методы, вы знаете все классы специализаторов, для которых соответствующий аргумент является экземпляром. В типичном случае, если два специализатора класса отличаются, то один будет подклассом другого. В этом случае специализатор, именующий подкласс, считается более специфичным. Поэтому метод, который специализирован для счета с классом checking-account будет рассматриваться как более специфичный, чем метод, специализированный для класса bank-account .

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

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

<p>Стандартный комбинатор методов</p>

Теперь, когда вы понимаете как подходящие методы находятся и сортируются, вы готовы поближе взглянуть на последний шаг как отсортированный список методов комбинируется в один эффективный метод. По умолчанию обобщенные функции используют так называемый "стандартный комбинатор методов". Стандартный комбинатор объединяет методы таким образом, что CALL-NEXT-METHOD работает как это вы уже увидели сначала запускается наиболее специфичный метод, и каждый метод может передать управление следующему методу используя CALL-NEXT-METHOD .

Однако тут есть больше возможностей. Методы, которые я обсуждал, называются основными методами. Основные методы (как и предполагает их имя) отвечают за реализацию основной функциональности обобщенных функций. Стандартный комбинатор методов также поддерживает три вида вспомогательных методов: :before , :after и :around . Определение вспомогательных методов записывается с помощью DEFMETHOD также как и для основных методов, но кроме этого, между именем метода и списком параметров указывается квалификатор метода, который именует тип метода. Например, метод :before для функции withdraw , которые специализирует параметр account для класса bank-account будет начинаться со следующей строки:

(defmethod withdraw :before ((account bank-account) amount) ...)

Каждый вид вспомогательных методов комбинируется в эффективный метод разными способами. Все применимые методы :before (не только наиболее специфические) запускаются как часть эффективного метода. Они запускаются (как и предполагается их именем) до наиболее специфического основного метода и запускаются, начиная с самого специфического метода. Таким образом, методы :before могут быть использованы для выполнения любой подготовительной работы, которая может понадобиться, чтобы убедиться что основной метод может быть выполнен. Например, вы можете использовать метод :before специализированный для checking-account для реализации защиты от перерасхода для чековых счетов:

(defmethod withdraw :before ((account checking-account) amount)

(let ((overdraft (- amount (balance account))))

(when (plusp overdraft)

(withdraw (overdraft-account account) overdraft)

(incf (balance account) overdraft))))

Этот метод :before имеет три преимущества по сравнению с использованием основного метода. Во первых, он явно показывает как метод изменяет общее поведение функции withdraw он не пересекается с основным поведением и не изменяет возвращаемое значение.

Следующим преимуществом является то, что главный метод, специализированный для класса, более специфичного, чем checking-account не будет противоречить этому методу :before , что позволяет автору подкласса checking-account более легко расширить поведение withdraw , при этом сохраняя старую функциональность.

И наконец, поскольку метод :before не должен вызывать CALL-NEXT-METHOD для передачи управления оставшимся методам, нет возможности сделать ошибку, забыв указать эту функцию.

Другие вспомогательные методы также организуются в эффективные методы способами, которые отражены в их именах. Все методы :after запускаются после основного метода в порядке, когда наиболее специфичный метод вызывается последним (порядок противоположен вызову методов :before ). Таким образом, методы :before и :after комбинируются для создания вложенных обвязок вокруг основной функциональности, обеспечиваемой основными методами каждый более специфичный метод :before получит шанс сделать что-то для менее специфичного метода :before , основные методы могут успешно выполняться, и затем каждый более специфичный метод :after получает шанс убрать что-то за основным методом и менее специфичными методами :after .

И наконец, методы :around комбинируются практически также как и основные методы, за исключением того, что они выполняются "вокруг" остальных методов. Так что код наиболее специфического метода :around запускается до любого кода. Внутри кода метода :around , вызов CALL-NEXT-METHOD приведет к тому, что будет выполняться код следующего метода :around , или, при вызове из наименее специфического метода :around , приведет к выполнению цепочки методов :before , основного метода и затем методов :after . Почти все методы :around будут иметь в своем коде вызов CALL-NEXT-METHOD , поскольку метод :around не будет полностью реализовывать (перехватывать) действия обобщенной функции и всех ее методов, за исключением более специфичных методов :around . FIXME Последнее предложение и в оригинале рвёт мозг. Я бы попроще объяснил: мол, поскольку методы around срабатывают первыми и наследуются "вовнутрь", то отсутствие call-next-method в любом из них повлечёт невызов всех менее специфичных around и вообще всех before, after и частей собственно метода. Такое поведение нетривиально и чревато, но допустимо для достижения определённых целей (см. ниже).

Иногда требуется полный перехват действий, но обычно, методы :around используются для установки некоторого динамического контекста в котором будут выполняться остальные методы например, для связывания динамической переменной, или для установки обработчика ошибок (это я буду обсуждать в главе 19). Метод :around может не вызывать CALL-NEXT-METHOD в тех случаях, если он, например, возвращает кэшированое значение, которое было получено при предыдущих вызовах CALL-NEXT-METHOD . В любом случае, метод :around , не вызывающий CALL-NEXT-METHOD , ответственен за корректную реализацию семантики обобщенной функции для всех классов аргументов, для которых этот метод может применяться, включая и будущие подклассы.

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

<p>Другие комбинаторы методов</p>

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

Все простые комбинаторы используют одинаковую стратегию: вместо запуска наиболее специфического метода, и разрешения ему запускать менее специфичные методы через CALL-NEXT-METHOD , простые комбинаторы методов создают эффективный метод, содержащий код всех основных методов, расположенных по порядку, и обернутых вызовом к функции, макросу или специальному оператору, который и дал комбинатору методов соответствующее имя. Девять комбинаторов получили имена от операторов: + , AND , OR , LIST , APPEND , NCONC , MIN , MAX и PROGN . Простые комбинаторы поддерживают только два типа методов основные методы, которые объединяются так, как было описано выше, и методы :around , которые работают также, как и методы :around в стандартном комбинаторе.

Например, обобщенная функция, которая использует комбинатор методов + , вернет сумму всех результатов, возвращенных вызванными основными методами. Отметьте, что комбинаторы AND и OR не обязательно будут выполнять все основные методы, поскольку эти макросы могут использовать сокращенную схему работы обобщенная функция, использующая комбинатор AND вернет значение NIL сразу же, как один из методов вернет его, или в противном вернет значение, возвращенное последним вызванным методом. Аналогичным образом, комбинатор OR вернет первое значение не равное- NIL , возвращенное любым из его методов.

Для определения обобщенной функции, которая использует конкретный комбинатор методов, вы должны указать опцию :method-combination при объявлении DEFGENERIC . Значение, указанное данной опцией определяет имя комбинатора методов, который вы хотите использовать. Например, для определения обобщенной функции priority , которая возвращает сумму значений, возвращаемых отдельными методами, используя комбинатор методов + , вы можете написать следующий код:

(defgeneric priority (job)

(:documentation "Return the priority at which the job should be run.")

(:method-combination +))

По умолчанию, все эти комбинаторы методов комбинируют методы в порядке начиная с наиболее специфичного. Однако вы можете изменить порядок, путем указания ключевого слова :most-specific-last после имени комбинатора в объявлении функции с помощью DEFGENERIC . Порядок скорее всего не имеет значения если вы используете комбинатор + и методы не имеют побочных эффектов, но в целях демонстрации, я изменю код priority чтобы он использовал порядок most-specific-last :

(defgeneric priority (job)

(:documentation "Return the priority at which the job should be run.")

(:method-combination + :most-specific-last))

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

(defmethod priority + ((job express-job)) 10)

Это делает ясным, когда вы смотрите на определение метода, который является частью конкретной обобщенной функции.

Все простые встроенные комбинаторы методов поддерживают методы :around , которые работают также как и методы :around в стандартном комбинаторе: наиболее специфичный метод :around выполняется до любого метода, и он может использовать CALL-NEXT-METHOD для передачи контроля менее специфичному методу :around до тех пор, пока не будет достигнут основной метод. Опция :most-specific-last не влияет на порядок вызова методов :around . И, как я отметил ранее, встроенные комбинаторы методов не поддерживают методы :before и :after .

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

Честно говоря, примерно в 99 процентах случаев вам будет достаточно стандартного комбинатора методов. Оставшийся один процент случаев скорее всего будет обработан простыми встроенными комбинаторами методов. Но если вы попадете в ситуацию (вероятность - примерно одна сотая процента), когда вам не будет подходить ни один из встроенных комбинаторов, то вы можете посмотреть на описание DEFINE-METHOD-COMBINATION в вашем любимом справочнике по Common Lisp.

<p>Мультиметоды</p>

Методы, которые явно специализируют более одного параметра обобщенной функции называются мультиметодами. Мультиметоды - это то, в чем реально расходятся обобщенные функции и передача сообщений. Мультиметодов не существует в системах с передачей сообщенийFIXME Мультиметоды не подходят языкам с передачей сообщений , поскольку они не относятся к конкретному классу; вместо этого, каждый мультиметод определяет часть реализации конкретной обобщенной функции, которая применяется тогда, когда эта функция вызывается с аргументами, которые соответствуют всем специализированным параметрам.

FIXME (начало выделенного блока)

<p>Мультиметоды против перегрузки методов</p>

Программисты, использовавшие статически типизованные языки с системами передачи сообщений, такие как Java и C++, могут подумать, что мультиметоды похожи на такую возможность этих языков, как перегрузка методов. Однако эти две возможности языка в достаточной мере отличаются, поскольку перегруженные методы выбираются во время компиляции, основываясь на информации об аргументах, полученной во время компиляции, а не во время исполнения. Чтобы посмотреть как это работает, рассмотрим два следующих класса на языке Java:

public class A {

public void foo(A a) { System.out.println("A/A"); }

public void foo(B b) { System.out.println("A/B"); }

}

public class B extends A {

public void foo(A a) { System.out.println("B/A"); }

public void foo(B b) { System.out.println("B/B"); }

}

Теперь посмотрим, что случится, когда вы запустите метод main из этого класса.

public class Main {

public static void main(String[] argv) {

A obj = argv[0].equals("A") ? new A() : new B();

obj.foo(obj);

}

}

Когда вы заставляете main создать экземпляр A , она выдаст A/A , как вы и ожидали.

bash$ java com.gigamonkeys.Main A

A/A

Однако, если вы заставите main создать экземпляр B , то настоящий тип объекта obj будет принят во внимание не полностью.

bash$ java com.gigamonkeys.Main B

B/A

Если бы перегруженные методы работали также как и мультиметоды в Common Lisp, то программа бы выдала ожидаемый результат: B/B . Существует возможность реализации множественной диспатчеризации для систем с передачей сообщений вручную, но это будет противоречить модели системы с передачей сообщений, поскольку метод с множественной диспатчеризацией не должен относиться ни к одному из классов.

FIXME конец блока

Мультиметоды полезны в ситуациях, когда вы не знаете к какому классу определенное поведение должно относиться (в языках с передачей сообщений). Звук, который издает барабан, когда вы стучите по нему палочкой, является функцией барабана, или функцией палочки? Конечно, он принадлежит обоим предметам. Для моделирования такой ситуации в Common Lisp, вы просто определяете функцию beat , которая принимает два аргумента.

(defgeneric beat (drum stick)

(:documentation

"Produce a sound by hitting the given drum with the given stick."))

Затем, вы можете определять разные мультиметоды для реализации beat для комбинаций, которые вам нужны. Например:

(defmethod beat ((drum snare-drum) (stick wooden-drumstick)) ...)

(defmethod beat ((drum snare-drum) (stick brush)) ...)

(defmethod beat ((drum snare-drum) (stick soft-mallet)) ...)

(defmethod beat ((drum tom-tom) (stick wooden-drumstick)) ...)

(defmethod beat ((drum tom-tom) (stick brush)) ...)

(defmethod beat ((drum tom-tom) (stick soft-mallet)) ...)

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

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

<p>Продолжение следует ...</p>

Я описал основы (и немного больше) обобщенных функций "глаголов" объектной системы Common Lisp. В следующей главе я покажу вам как определять ваши собственные классы.

17
<p>17. Переходим к объектам: Классы</p>

Если обобщенные функции являются глаголами объектной системы, то классы являются существительными. Как я упоминал в предыдущей главе, все значения в программах на Common Lisp являются экземплярами какого-то из классов. Более того, все классы образуют иерархию на вершине которой находится класс T .

Иерархия классов состоит из двух основных семейств классов: встроенных и определенных пользователем. Классы, которые представляют типы данных, которые мы изучали до сих пор такие как INTEGER , STRING и LIST , являются встроенными. Они находятся в отдельном разделе иерархии классов, организованные соответствующими связями дочерних и родительских классов, и для работы с ними используются функции, которые мы обсуждалиFIXME которые я описывал на протяжении всей книги. Вы не можете унаследовать от этих классов, но как вы увидели в предыдущем разделе, вы можете определить специализированные методы для них, эффективно расширяя поведения этих классов. [191]

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

<p>DEFCLASS</p>

Вы можете создать собственный класс с помощью макроса DEFCLASS . Поскольку поведение класса определяется обобщенными функциями, и методами специализированными для класса, то DEFCLASS отвечает только за определение класса как типа данных.

Класс, как тип данных, состоит из трех частей: имени, отношения к другим классам и имен слотов. [192] Базовая форма DEFCLASS выглядит достаточно просто.

(defclass name (direct-superclass-name*)

(slot-specifier*))

Что такое классы, определенные пользователем?

"Определенные пользователем классы" термин не из стандарта языка. Определёнными пользователем классами я называю подклассы класса STANDARD-OBJECT , а также классы, у которых метакласс STANDARD-CLASS . Но поскольку я не собираюсь говорить о способах определения классов, которые не наследуют STANDARD-OBJECT и чей метакласс это не STANDARD-CLASS , вам можно не обращать на это внимания. Определённые пользователем не идеальный термин, поскольку так же реализация может называть некоторые классы.FIXME Определённые пользователем классы не идеальный термин, потому что реализация может определять некоторые классы одним способом. Но ещё большей путаницей будет называть эти классы стандартными, поскольку встроенные классы (например, INTEGER и STRING ) тоже стандартные, если не сказать больше, потому что они определены стандартом языка, но они не расширяют (не наследуют) STANDARD-OBJECT . Чтобы ещё больше запутать дело, пользователь может также определять классы, не наследующие STANDARD-OBJECT . В частности, макрос DEFSTRUCT тоже определяет новые классы. Но это во многом для обратной совместимости DEFSTRUCT появился раньше, чем CLOS и был изменен, чтоб определять классы, когда CLOS добавлялся в язык. Но создаваемые им классы достаточно ограничены по сравнению с классами, созданными с помощью DEFCLASS . Итак, я буду обсуждать только классы, создаваемые с помощью DEFCLASS , которые используют заданный по умолчанию метакласс STANDARD-CLASS и, за неимением лучшего термина, назову их "определёнными пользователем классами".

Также как с функциями и переменными, вы можете использовать в качестве имени класса любой символ. [193] Имена классов находятся в собственном пространстве имен, отдельно от имен функций и переменных, так что вы можете задать для класса то же самое имя, что и существующие функция и переменная. Вы будете использовать имя класса в качестве аргумента функции MAKE-INSTANCE , которая создает новые экземпляры классов, определенных пользователем.

Опция direct-superclass-names используется для указания имен классов, от которых будет проводиться наследование данного класса. Если ни одного класса не указано, то он будет унаследован от STANDARD-OBJECT . Все классы указанные в данной опции должны быть классами, определенными пользователем, чтобы быть увереным, что каждый новый класс происходит от STANDARD-OBJECT . STANDARD-OBJECT является подклассом T , так что все классы, определенные пользователем, являются частью одной иерархии классов, которая также содержит все встроенные классы.

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

(defclass bank-account () ...)

(defclass checking-account (bank-account) ...)

(defclass savings-account (bank-account) ...)

В разделе "Множественное наследование" я FIXME опишу, что означает указание более чем одного суперкласса в списке опции direct-superclass-names .

<p>Спецификаторы слотов</p>

Большая часть DEFCLASS состоит из списка спецификаторов слотов. Каждый спецификатор определяет слот, который будет частью экземпляра класса. Каждый слот в экземпляре является местом, который может хранить значение, к которому можно получить доступ через функцию SLOT-VALUE . SLOT-VALUE в качестве аргументов принимает объект и имя слота и возвращает значение нужного слота в данном объекте. Эта функция может использоваться вместе с SETF для установки значений слота в объекте.

Класс также наследует спецификаторы слотов от своих суперклассов, так что набор слотов, присутствующих в любом объекте, является объединением всех слотов, указанных в форме DEFCLASS для класса, а также указанных для всех его суперклассов.

По минимуму, спецификатор слота указывает его имя, так что спецификатор может быть простым именем. Например, вы можете определить класс bank-account с двумя слотами customer-name и balance , например, вот так:

(defclass bank-account ()

(customer-name

balance))

Каждый экземпляр этого класса содержит два слота: один для хранения имени клиента, а второй для хранения текущего баланса счета. Используя данное определение вы можете создать новые объекты bank-account с помощью MAKE-INSTANCE .

(make-instance 'bank-account) ==> #<BANK-ACCOUNT @ #x724b93ba>

Аргументом MAKE-INSTANCE является имя класса, а возвращаемым значением новый объект. [194] Печатное представление объекта определяется обобщенной функцией PRINT-OBJECT . В этом случае, подходящим методом будет тот, который предоставляется реализацией и специализированный для STANDARD-OBJECT . Поскольку, не каждый объект может быть выведен таким образом, чтобы потом быть считанным назад, то метод печати для STANDARD-OBJECT использует синтаксис #<> , который заставит процедуру чтения выдать ошибку, если он попытается прочитать его. Оставшаяся часть представления зависит от реализации, но обычно оно похоже на результат, приведенный выше, включая имя класса и некоторое значение, например, адрес объекта в памяти. В главе 23 вы увидите пример того, как определить метод для PRINT-OBJECT чтобы некоторые классы могли выдавать больше информации FIXME при печати .

Используя данное определение bank-account , новые объекты будут создаваться со слотами, которые не связаны со значениями. Любая попытка получить значение для несвязанного значения приведет к выдаче ошибки, так что вы должны задать значение до того, как вы будете считывать значения.

(defparameter *account* (make-instance 'bank-account)) ==> *ACCOUNT*

(setf (slot-value *account* 'customer-name) "John Doe") ==> "John Doe"

(setf (slot-value *account* 'balance) 1000) ==> 1000

Теперь вы можете получать значения слотов.

(slot-value *account* 'customer-name) ==> "John Doe"

(slot-value *account* 'balance) ==> 1000

<p>Инициализация объекта</p>

Поскольку мы не можем сделать ничего полезного с объектом, который имеет несвязанные слоты, то было бы хорошо иметь возможность создавать объекты с FIXME уже инициализированными слотами. Common Lisp имеетFIXME предоставляет три способа управления начальными значениями слотов. Первые два требуют добавления опций в спецификаторы слотов в DEFCLASS : с помощью опции :initarg вы можете указать имя, которое потом будет использоваться как именованный параметр при вызове MAKE-INSTANCE и переданное значение будет сохранено в слоте. Вторая опция :initform , позволяет вам указать выражение на Lisp, которое будет использоваться для вычисления значения, если при вызове MAKE-INSTANCE не был передан аргумент :initarg . В заключение, для полного контроля за инициализацией объекта, вы можете определить метод для обобщенной функции INITIALIZE-INSTANCE , которую вызывает MAKE-INSTANCE . [195]

Спецификатор слота, который включает опции, такие как :initarg или :initform , записывается как список, начинающийся с имени слота, за которым следуют опции. Например, если вы измените определение bank-account таким образом, чтобы позволить передавать имя клиента и начальный баланс при вызове MAKE-INSTANCE , а также чтобы установить для баланса начальное значение равное нулю, вы должны написать:

(defclass bank-account ()

((customer-name

:initarg :customer-name)

(balance

:initarg :balance

:initform 0)))

Теперь вы можете одновременно создавать счет и указывать значения слотов.

(defparameter *account*

(make-instance 'bank-account :customer-name "John Doe" :balance 1000))

(slot-value *account* 'customer-name) ==> "John Doe"

(slot-value *account* 'balance) ==> 1000

Если вы не передадите аргумент :balance при вызове MAKE-INSTANCE , то вызов SLOT-VALUE для слота balance будет получен вычислением формы, указанной опцией :initform . Но, если вы не передадите аргумент :customer-name , то слот customer-name будет не связан, и попытка считывания значения из него, приведет к выдаче ошибки.

(slot-value (make-instance 'bank-account) 'balance) ==> 0

(slot-value (make-instance 'bank-account) 'customer-name) ==> error

Если вы хотите убедиться, что имя клиента было задано при создании счета, то вы можете выдать ошибку в начальном выражении ( initform ), поскольку оно будет вычислено только если начальное значение ( initarg ) не было задано. Вы также можете использовать начальные формы, которые создают разные значения при каждом запуске начальное выражение вычисляется заново для каждого объекта. Для эксперементирования с этими возможностями, вы можете изменить спецификатор слота customer-name и добавить новый слот, account-number , который инициализируется значением увеличивающегося счетчика.

(defvar *account-numbers* 0)

(defclass bank-account ()

((customer-name

:initarg :customer-name

:initform (error "Must supply a customer name."))

(balance

:initarg :balance

:initform 0)

(account-number

:initform (incf *account-numbers*))))

В большинстве случаев, комбинации опций :initarg и :initform будет достаточно для нормальной инициализации объекта. Однако, хотя начальное выражение может быть любым выражением Lisp, оно не имеет доступа к инициализируемому объекту, так что оно не может инициализировать один слот, основываясь на значении другого. Для выполнения такой задачи вам необходимо определить метод для обобщенной функции INITIALIZE-INSTANCE .

Основной метод INITIALIZE-INSTANCE , специализированный для STANDARD-OBJECT берет на себя заботу об инициализации слотов, основываясь на данных, заданных опциями :initarg и :initform . Поскольку вы не захотите вмешиваться в этот процесс, то наиболее широко применяемым способом является определение метода :after , специализированного для вашего класса. [196] Например, предположим, что вы хотите добавить слот account-type , который должен быть установлен в значение :gold , :silver или :bronze , основываясь на начальном балансе счета. Вы можете изменить определение класса на следующее, добавляя слот account-type без каких либо опций:

(defclass bank-account ()

((customer-name

:initarg :customer-name

:initform (error "Must supply a customer name."))

(balance

:initarg :balance

:initform 0)

(account-number

:initform (incf *account-numbers*))

account-type))

После этого вы можете определить метод :after для INITIALIZE-INSTANCE , который установит значение слота account-type , основываясь на значении, которое было сохранено в слоте balance . [197]

(defmethod initialize-instance :after ((account bank-account) &key)

(let ((balance (slot-value account 'balance)))

(setf (slot-value account 'account-type)

(cond

((>= balance 100000) :gold)

((>= balance 50000) :silver)

(t :bronze)))))

Указание &key в списке параметров требуется чтобы сохранить список параметров подобнымFIXME соответствующим списку параметров обобщенной функции список параметров, указанный для функции INITIALIZE-INSTANCE включает &key чтобы позволить отдельным методам передавать собственные именованные параметры, но при этом, он не требует указания конкретных названий. Таким образом, каждый метод должен указывать &key , даже если он не указывает ни одного именованного параметра.

С другой стороны, если метод INITIALIZE-INSTANCE , специализированный для конкретного класса, указывает именованный параметр, то этот параметр становится допустимым параметром для функции MAKE-INSTANCE при создании экземпляра данного класса. Например, если банк иногда платит процент начального баланса в качестве премии при открытии счета, то вы можете реализовать эту функцию, используя метод INITIALIZE-INSTANCE , который получает именованный аргумент, указывающий процент премии, например вот так:

(defmethod initialize-instance :after ((account bank-account)

&key opening-bonus-percentage)

(when opening-bonus-percentage

(incf (slot-value account 'balance)

(* (slot-value account 'balance) (/ opening-bonus-percentage 100)))))

Путем определения метода INITIALIZE-INSTANCE , вы делаете :opening-bonus-percentage допустимым аргументом функции MAKE-INSTANCE при создании объекта bank-account .

CL-USER> (defparameter *acct* (make-instance

'bank-account

:customer-name "Sally Sue"

:balance 1000

:opening-bonus-percentage 5))

*ACCT*

CL-USER> (slot-value *acct* 'balance)

1050

<p>Функции доступа</p>

MAKE-INSTANCE и SLOT-VALUE дают вам возможности для создания и работы с экземплярами ваших классов. Все остальные операции могут быть реализованы в терминах этих двух функций. Однако, как знает всякий, знакомый с принципами правильного объектно-ориентированного программирования, прямой доступ к слотам (полям или переменным-членам) объекта может привести к получению уязвимого кода. Проблема заключается в том, что прямой доступ к слотам делает ваш код слишком связанным с конкретной структурой классов. Например, предположим, что вы решили изменить определение bank-account таким образом, что вместо хранения текущего баланса в виде числа, вы храните его в виде списка списаний и помещений денег на счет, вместе с датами этих операций. Код, который имеет прямой доступ к слоту balance скорее всего будет сломан, если вы измените определение класса, удалив слот или храня список в данном слоте. С другой стороны, если вы определить функцию balance , которая осуществляет доступ к слоту, то вы можете позже переопределить ее, чтобы сохранить ее поведение, даже если изменится внутреннее представление данных. И код, который использует такую функцию будет продолжать нормально работать не требуя внесения изменений.

Другим преимуществом использования функций доступа вместо прямого доступа к слотам через SLOT-VALUE является то, что их использование позволяет вам ограничить возможность модификации слота FIXME из внешнего кода . [198] Для пользователей класса bank-account может быть удобным использование функций доступа для получения текущего баланса, но вы можете захотеть, чтобы все изменения баланса производились через другие предоставляемые вами функции, такие как deposit и withdraw . Если клиент знает, что он сможет работать с объектами только через определенный набор функций, то вы можете предоставить ему функцию balance , но сделать так, чтобы для нее нельзя было выполнить SETF , чтобы баланс был доступен только для чтения.

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

Определение функции, которая читает содержимое слота balance является тривиальным.

(defun balance (account)

(slot-value account 'balance))

Однако, если вы знаете, что вы будете определять подклассы для bank-account , то может быть хорошей идеей определение balance в качестве обобщенной функции. Таким образом вы можете определить разные методы для balance для некоторых подклассов, или расширить ее возможности с помощью вспомогательных методов. Так что вместо предыдущего примера можете написать следующее:

(defgeneric balance (account))

(defmethod balance ((account bank-account))

(slot-value account 'balance))

Как я только что обсуждал, вы не хотите, чтобы пользователь имел возможность устанавливать баланс напрямую, но для других слотов, таких как customer-name , вы также можете захотеть предоставить функцию для установки их значений. Наиболее понятным способом будет определение такой функции как SETF -функции.

SETF -функция является способом расширения функциональности SETF , определяя новый вид места (place), для которого известно как устанавливать его значение. Имя SETF -функции является списком из двух элементов, где первый элемент является символом setf , а второй другим символом, обычно именем функции, которая используется для доступа к месту, значение которого будет устанавливать функция SETF . SETF -функция может получать любое количество аргументов, но первым аргументом всегда является значение, присваиваемое выбранному месту. [199] Например, вы можете определить SETF -функцию для установки значения слота customer-name в классе bank-account следующим образом:

(defun (setf customer-name) (name account)

(setf (slot-value account 'customer-name) name))

После вычисления этого определения, выражения, подобные этому:

(setf (customer-name my-account) "Sally Sue")

будут компилироваться как вызов SETF -функции, которую вы только что определили с значением "Sally Sue" в качестве первого аргумента, и значением my-account в качестве второго аргумента.

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

(defgeneric (setf customer-name) (value account))

(defmethod (setf customer-name) (value (account bank-account))

(setf (slot-value account 'customer-name) value))

И конечно, вы также можете определить функцию чтения для customer-name .

(defgeneric customer-name (account))

(defmethod customer-name ((account bank-account))

(slot-value account 'customer-name))

Это позволит вам писать следующим образом:

(setf (customer-name *account*) "Sally Sue") ==> "Sally Sue"

(customer-name *account*) ==> "Sally Sue"

Нет ничего сложного в написании этих функций доступа, но написание этих функций вручную просто не соответствует The Lisp Way. Так что DEFCLASS поддерживает три опции для слотов, которые позволяют вам автоматически создавать функции чтения и записи значений отдельных слотов.

Опция :reader указывает имя, которое будет использоваться как имя обобщенной функции, которая принимает объект в качестве своего единственного аргумента. Когда вычисляется DEFCLASS , то создается соответствующая обобщенная функция (если она еще не определена)DELETEME скобки . После этого для данной обобщенной функции создается метод, специализированный для нового класса и возвращающий значение слота. Имя функции может быть любым, но обычно используют то же самое имя, что и имя самого слота. Так что вместо явного задания обобщенной функции balance и метода для нее, как это было показано раньше, вы можете просто изменить спецификатор слота balance в определении класса bank-account на следующее:

(balance

:initarg :balance

:initform 0

:reader balance)

Опция :writer используется для создания обобщенной функции и метода для установки значения слота. Создаваемая функция и метод следуют требованиям для SETF -функции, получая новое значение как первый аргумент, и возвращая его в качестве результата, так что вы можете определить SETF -функцию задавая имя, такое как (setf customer-name) . Например, вы можете определить методы чтения и записи для слота customer-name , просто изменяя спецификатор слота на следующее определение:

(customer-name

:initarg :customer-name

:initform (error "Must supply a customer name.")

:reader customer-name

:writer (setf customer-name))

Поскольку достаточно часто требуется определение обеих функций доступа, то DEFCLASS также имеет опцию :accessor , которая создает и функцию чтения, и соответствующую SETF -функцию. Так что вместо предыдущего примера можно написать следующим образом:

(customer-name

:initarg :customer-name

:initform (error "Must supply a customer name.")

:accessor customer-name)

В заключение, опишу еще одну опцию, о которой вы должны знать: опция :documentation позволяет вам задать строку, которая описывает данный слот. Собирая все в кучу и добавляя методы чтения для слотов account-number и account-type , определение DEFCLASS для класса bank-account будет выглядеть примерно так:

(defclass bank-account ()

((customer-name

:initarg :customer-name

:initform (error "Must supply a customer name.")

:accessor customer-name

:documentation "Customer's name")

(balance

:initarg :balance

:initform 0

:reader balance

:documentation "Current account balance")

(account-number

:initform (incf *account-numbers*)

:reader account-number

:documentation "Account number, unique within a bank.")

(account-type

:reader account-type

:documentation "Type of account, one of :gold, :silver, or :bronze.")))

<p>''WITH-SLOTS'' и ''WITH-ACCESSORS''</p>

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

Это как раз тот случай, для которого и предназначен макрос SLOT-VALUE ; однако, он также достаточно многословен. Если функция или метод осуществляют доступ к одному и тому же слоту несколько раз, то исходный код будет засорен вызовами функций доступа и SLOT-VALUE . Например, даже достаточно простой метод, такой как следующий пример, который вычисляет пеню для bank-account если баланс снижается ниже некоторого минимума, будет засорен вызовами balance и SLOT-VALUE :

(defmethod assess-low-balance-penalty ((account bank-account))

(when (< (balance account) *minimum-balance*)

(decf (slot-value account 'balance) (* (balance account) .01))))

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

(defmethod assess-low-balance-penalty ((account bank-account))

(when (< (slot-value account 'balance) *minimum-balance*)

(decf (slot-value account 'balance) (* (slot-value account 'balance) .01))))

Два стандартных макроса WITH-SLOTS и WITH-ACCESSORS , могут помочь избавиться от этого мусора. Оба макроса создают блок кода, в которых могут использоваться простые имена переменных для обращения к слотам определенного объекта. WITH-SLOTS предоставляет прямой доступ к слота, также как при использовании SLOT-VALUE , в то время как WITH-ACCESSORS предоставляет сокращенный способ вызова функций доступа.

Базовая форма WITH-SLOTS выглядит следующим образом:

(with-slots (slot*) instance-form

body-form*)

Каждый элемент списка slot может быть либо именем слота,FIXME которое также будет именем переменной, либо списком из двух элементов, где первый аргумент является именем, которое будет использоваться как переменная, а второй именем соответствующего слота. Выражение instance-form вычисляется один раз для получения объекта, к слотам которого будет производиться доступ. Внутри тела макроса, каждое вхождение имени переменной преобразуется в вызов SLOT-VALUE с использованием объекта и имени слота в качестве аргументов. [200] Таким образом, вы можете переписать assess-low-balance-penalty вот так:

(defmethod assess-low-balance-penalty ((account bank-account))

(with-slots (balance) account

(when (< balance *minimum-balance*)

(decf balance (* balance .01)))))

или используя списочную запись, вот так:

(defmethod assess-low-balance-penalty ((account bank-account))

(with-slots ((bal balance)) account

(when (< bal *minimum-balance*)

(decf bal (* bal .01)))))

Если вы определили balance с использованием опции :accessor , а не :reader , то вы также можете использовать макрос WITH-ACCESSORS . Форма WITH-ACCESSORS совпадает сFIXME такая же как WITH-SLOTS за тем исключением, что каждый элемент списка слотов является списком из двух элементов, содержащих имя переменной и имя функции доступа. Внутри тела WITH-ACCESSORS , ссылка на одну из переменных, аналогична вызову соответствующей функции доступа. Если функция доступа разрешает выполнение SETF , то тоже самое возможно и для переменной.

(defmethod assess-low-balance-penalty ((account bank-account))

(with-accessors ((balance balance)) account

(when (< balance *minimum-balance*)

(decf balance (* balance .01)))))

Первое вхождение balance является именем переменной, а второе именем функции доступа; они не обязательно должны быть одинаковыми. Например, вы можете написать метод для слияния двух счетов, используя два вызова WITH-ACCESSORS , для каждого из счетов.

(defmethod merge-accounts ((account1 bank-account) (account2 bank-account))

(with-accessors ((balance1 balance)) account1

(with-accessors ((balance2 balance)) account2

(incf balance1 balance2)

(setf balance2 0))))

Выбор между использованием WITH-SLOTS и WITH-ACCESSORS примерно таков, как и выбор между использованием SLOT-VALUE и функций доступа: низкоуровневый код, которые обеспечивает основную функциональность класса, может использовать SLOT-VALUE или WITH-SLOTS для прямой работы со слотамиFIXME , если функции доступа не поддерживают нужный стиль работы, или FIXME если хочется явно избежать использования вспомогательных методов, которые могут быть определены для функций доступа. Но в общем вы должны использовать функции доступа или WITH-ACCESSORS , если только у вас не имеются конкретные причины не делать этого.

<p>Слоты, выделяемые для классов</p>

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

Однако доступ к слотам со значением :class производится также как и для слотов со значением :instance доступ производится с помощью SLOT-VALUE или функции доступа, что значит, что вы можете получить доступ только через экземпляр класса, хотя это значение не хранится в этом экземпляре. Опции :initform и :initarg имеют точно такой же эффект, за тем исключением, что начальное выражение вычисляется один раз, при определении класса, а не при создании экземпляра. С другой стороны, передача начальных аргументов MAKE-INSTANCE установит значение, затрагивая все экземпляры данного класса.

Поскольку вы не можете получить слот, выделенный для класса, не имея экземпляра класса, то такие слоты не являются полным аналогам статическим членам в таких языках как Java, C++ и Python. [201] В значительной степени, слоты выделенные для класса в основном используются для уменьшения потребляемой памяти; если вы создаете много экземпляров класса и они все имеют ссылку на один и тот же объект (например, список разделяемых ресурсов), то вы можете сократить использование памяти путем объявления такого слота, выделяемым для класса, а не для экземпляра.

<p>Слоты и наследование</p>

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

В Common Lisp конкретный объект может иметь только один слот с определенным именем. Однако возможно, что в иерархии наследования класса несколько классов будут иметь слоты с одним и тем же именем. Это может случиться либо потому, что подкласс включает спецификатор слота с тем же именем, что и слот указанный в суперклассе, либо потому, что несколько суперклассов имеют слоты с одним и тем же именем.

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

С другой стороны, опции :initargs не должны быть взаимоисключающими каждая опция :initarg создает именованный параметр, который может быть использован для инициализации слота; множественные параметры не приводят к конфликту, так что новый спецификатор слота будет содержать все опции :initargs . Пользователи, вызывающиеFIXME Вызывающие MAKE-INSTANCE могут использовать любое из имен, указанных в :initargs для инициализации слота. Если пользовательFIXME Если вызывающий указывает несколько именованных аргументов, которые инициализируют один и тот же слот, то используется то, которое стоит левее всех остальных в списке аргументов MAKE-INSTANCE .

Унаследованные опции :reader , :writer и :accessor не включаются в новый спецификатор слота, поскольку методы, созданные при объявлении суперкласса будут автоматически применяться к новому классу. Однако новый класс может создать свои собственные функции доступа, путем объявления собственных опций :reader , :writer или :accessor .

И в заключение, опция :allocation , подобно :initform , определяется наиболее специализированным классом, определяющим данный слот. Таким образом, возможно сделать так, что экземпляры одного класса будут использовать слот с опцией :class , а экземпляры его подклассов могут иметь свои собственные значения опции :instance для слота с тем же именем. А их подклассы, в свою очередь, могут переопределить этот слот с опцией :class , так что все экземпляры данного класса снова будут делить между собой единственный экземпляр слота. В последнем случае, слот, разделяемый экземплярами под-подклассов отличается от слота, разделяемого оригинальным суперклассом.

Например, у вас имеются следующие классы:

(defclass foo ()

((a :initarg :a :initform "A" :accessor a)

(b :initarg :b :initform "B" :accessor b)))

(defclass bar (foo)

((a :initform (error "Must supply a value for a"))

(b :initarg :the-b :accessor the-b :allocation :class)))

При создании экземпляра класса bar , вы можете использовать унаследованный начальный аргумент :a для указания значения для слота a и, в действительности, должны сделать это для того, чтобы избежать ошибок, поскольку опция :initform определенная bar замещает опцию, унаследованную от foo . Для инициализации слота b , вы можете использовать либо унаследованный аргумент :b , либо новый аргумент :the-b . Однако, поскольку для слота b в определении bar указана опция :allocation , то указанное значение будет храниться в слоте, используемом всеми экземплярами bar . Доступ к этому слоту может быть может быть осуществлен либо с помощью метода обобщенной функции b , специализированного для foo , либо с помощью нового метода обобщенной функции the-b , который специализирован для bar . Для доступа к слоту a классов foo или bar , вы продолжите использовать обобщенную функцию a .

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

<p>Множественное наследование</p>

Все классы, которые вы до сих пор видели имели только один суперкласс. Common Lisp также поддерживает множественное наследование класс может иметь несколько прямых суперклассов, наследуя соответствующие методы и спецификаторы слотов из всех этих классов.

Множественное наследование не вносит кардинальных изменений в механизмы наследования, которые я уже обсуждал каждый класс определенный пользователем уже имеет несколько суперклассов, поскольку они все наследуются от STANDARD-OBJECT , который унаследован от T , так что по крайней мере имеется два суперкласса. Затруднение, которое вносит множественное наследование заключается в том, что класс может иметь более одного непосредственного суперкласса. Это усложняет понятие специфичности класса, которое используется при построении эффективных методов для обобщенных функции и при слиянии спецификаторов слотов.

Так что, если классы должныFIXME если бы классы могли иметь только один непосредственный суперкласс, то упорядочение классов по специфичности будет тривиальным класс и все его суперклассы могут быть выстроены в линию начиная с самого класса, за которым следует один прямой суперкласс, за которым следует его суперкласс, и так далее, до класса T . Но когда класс имеет несколько непосредственных суперклассов, то эти классы обычно не относятся друг к другу конечно, если один класс был подклассом другого, вам не нужно образовывать подкласс от обоих. В этом случае, правила по которому подклассы более специфичны чем суперклассы недостаточно для упорядочения всех суперклассов. Так что Common Lisp использует второе правило, которое сортирует не относящиеся друг к другу суперклассы по порядку в котором они перечислены в определении непосредственных суперклассов в DEFCLASS классы, указанные в списке первыми, считаются более специфичными, чем классы, указанные в списке последними. Это правило считается достаточно произвольным, но оно позволяет каждому классу иметь линейный список приоритетов FIXME список следования классов , который может использоваться для определения того, какой из суперклассов будет считаться более специфичным чем другой. Однако заметьте, что нет глобального упорядочения классов каждый класс имеет собственный список приоритетов FIXME список следования классов , и одни и те же классы могут стоять на разных позициях в списках приоритетовFIXME списках следования классов разных классов.

Для того, чтобы увидеть как это работает, давайте добавим новый класс к нашему банковскому приложению: money-market-account . Этот счет объединяет в себе характеристики чекового ( checking-account ) и сберегательного ( savings-account ) счетов: клиент может выписывать чеки, но кроме того он получает проценты. Вы можете определить его следующим образом:

(defclass money-market-account (checking-account savings-account) ())

Список приоритетов класса money-market-account будет следующим:

(money-market-account

checking-account

savings-account

bank-account

standard-object

t)

Заметьте, как список удовлетворяет обоим правилам: каждый класс появляется раньше своих суперклассов, а checking-account и savings-account располагаются в порядке, указанном в DEFCLASS .

Этот класс не определяет своих собственных слотов, но унаследует слоты от обоих суперклассов, включая слоты, которые те унаследовали от своих суперклассов. Аналогичным образом, все методы, которые применимы к любому из классов в списке приоритетов, также будут применимы к объекту money-market-account . Поскольку все спецификаторы одинаковых слотов объединяются, то не имеет значения, что money-market-account дважды наследует одни и те же слоты из bank-account . [202]

Множественное наследование наиболее просто понять когда суперклассы предоставляют совершенно независимые наборы слотов и методов. Например, money-market-account унаследует слоты и поведение по работе с чеками от checking-account , а слоты и поведение по вычислению процентов от savings-account . Вам не нужно беспокоиться о списке приоритетов классаFIXME списке следования классов для методов и слотов, унаследованных только от одного или другого суперкласса.

Однако, также возможно унаследовать методы для одних и тех же обобщенных функций от различных суперклассов. В этом случае, в игру включается список приоритетов классов.FIXME список следования классов. Например, предположим, что банковское приложение определяет обобщенную функцию print-statement , которая используется для генерации месячных отчетов. Вероятно, что уже будут определены методы print-statement специализированные для checking-account и savings-account . Оба этих метода будут применимы для экземпляров класса money-market-account , но тот, который специализирован для checking-account будет считаться более специфичным, чем специализированный для savings-account , поскольку checking-account имеет приоритет перед savings-account в списке приоритетов классовFIXME списке следования классов money-market-account .

Предполагается, что унаследованные методы являются основными методами, и вы не определяли других методов, специализированных для checking-account , которые будут использоваться, если вы выполните print-statement для money-market-account . Однако, это не обязательно даст вам то поведение, которое вы хотите, поскольку вы хотите чтобы отчет для нового счета содержал элементы из отчетов по чековому и сберегательному счетов.

Вы можете изменить поведение print-statement для money-market-accounts несколькими способами. Непосредственным способом является определение основного метода, специализированного для money-market-account . Это даст вам полный контроль за поведением, но вероятно потребует написания кода для опций, которые я буду вскоре обсуждать. Проблема заключается в том, что хотя вы можете использовать CALL-NEXT-METHOD для передачи управления "вверх", следующему методу, а именно, специализированному для checking-account , но не существует способа вызвать конкретный менее специфичный метод, например, специализированный для savings-account . Так что если вы хотите иметь возможность использования кода, который создает часть отчета, специфичную для savings-account , то вам нужно разбить этот код на отдельные функции, которые вы сможете вызвать напрямую из методов print-statement классов money-market-account и savings-account .

Другой возможностью является написание основных методов всех трех классов так, чтобы они вызывали CALL-NEXT-METHOD . Тогда метод, специализированный для money-market-account будет использовать CALL-NEXT-METHOD для вызова метода, специализированного для checking-account . Затем, этот метод вызовет CALL-NEXT-METHOD , что приведет к запуску метода для savings-account , поскольку он будет следующим наиболее специфичным методом в списке приоритетов классов для money-market-account .

Конечно, если вы не хотите полагаться на соглашения о стиле кодирования (что каждый метод будет вызывать CALL-NEXT-METHOD ) чтобы убедиться, что все применимые методы будут вызваны в некоторый момент времени, вы должны подумать об использовании вспомогательных методов. В этом случае, вместо определения основного метода print-statement для checking-account и savings-account , вы можете определить их как методы :after , оставляя один основной метод для bank-account . Так что print-statement , вызванный для money-market-account , выдаст базовую информацию о счете, которая будет выведена основным методом, специализированным для bank-account , за которым следуют дополнительные детали, выведенные методами :after специализированными для savings-account и checking-account . И если вы хотите добавить детали, специфичные для money-market-accounts , вы можете определить метод :after , специализированный для money-market-account , который будет выполнен последним.

Преимуществом использования вспомогательных методов является то, что становится понятным какие из методов является ответственным за реализацию обобщенной функции, и какие из них вносят дополнительные детали в работу функции. Недостатком этого подходя является то, что вы не получаете точного контроля за порядком, в котором будут выполняться вспомогательные методы если вы хотите, чтобы часть отчета, приготовленного для checking-account печаталась перед частью savings-account , то вы должны изменить порядок в котором money-market-account наследуются от этих классов. Но это достаточно трагическое изменение, которое затрагивает другие методы и унаследованные слоты. В общем, если вы обнаружите, что рассматриваете изменение списка непосредственных суперклассов как способ тонкой настройки поведения специфических методов, то вы скорее всего должны сделать шаг назад и заново обдумать ваш подход.

С другой стороны, если вы не заботитесь о порядке наследования, но хотите, чтобы он был последовательным для разных обобщенных функций, то использование вспомогательных методов может быть одним из методов. Например, если в добавление к print-statement вы имеете функцию print-detailed-statement , то вы можете реализовать обе функции используя методы :after для разных подклассов bank-account , и порядок частей и для основного и детального отчета будет одинаков.

<p>Правильный объектно-ориентированный дизайн</p>

Это все о главных возможностях объектной системы Common Lisp. Если у вас имеется большой опыт объектно-ориентированного программирования, вы вероятно увидите как возможности Common Lisp могут быть использованы для реализации правильного объектно-ориентированного дизайна. Однако, если у вас небольшой опыт объектно-ориентированного программирования, то вам понадобиться провести некоторое время чтобы освоиться с объектно-ориентированным мышлением. К сожалению это достаточно большой раздел, находящийся за пределами данной книги. Или, как указано в справочной странице по объектной системе Perl, "Теперь, вам нужно лишь выйти и купить книгу о методологии объектно-ориентированного дизайна, и провести с нею следующие шесть месяцев". Или вы можете продолжить чтение до практических глав, далее в этой книге, где вы увидите несколько примеров того, как эти возможности используются на практике. Однако сейчас, вы готовы к тому, чтобы взять перерыв и перейти от теории объектно-ориентированного программирования к другой теме как можно полезно использовать мощную, но немного загадочную функцию Common Lisp FORMAT.

18
<p>18. Несколько рецептов для функции FORMAT</p>

Функция FORMAT вместе с расширенным макросом LOOP - одна из двух возможностей Common Lisp, которые вызывают сильную эмоциональную реакцию у многих пользователей Common Lisp. Некоторые их любят, другие ненавидят [203]

Поклонники FORMAT любят ее за мощь и краткость, в то время как противники ее ненавидят за потенциал для возникновения ошибок и непрозрачность. Сложные управляющие строки FORMAT имеют иногда подозрительное сходство с помехами на экране [204], но FORMAT остается популярным среди программистов на Common Lisp, которые хотят формировать небольшие куски удобочитаемого текста без необходимости нагромождать кучи формирующего вывод кода. Хотя управляющие строки FORMAT могут быть весьма замысловаты, но во всяком случае единственное FORMAT -выражение не сильно замусорит ваш код. Предположим например, что вы хотите напечатать значения в списке, разделенные запятыми. Вы можете написать так:

(loop for cons on list

do (format t "~a" (car cons))

when (cdr cons) do (format t ", "))

Это не очень плохо, но любой, кто будет читать этот код, должен будет мысленно проанализировать его, прежде чем понять, что все, что он делает - это печать содержимого списка list на стандартный вывод. С другой стороны, вы можете с одного взгляда определить, что следующее выражение печатает список в некоторой форме на стандартный вывод:

(format t "~{~a~^, ~}" list)

Если вам важна форма, которую примет вывод, тогда вы можете изучить управляющую строку, но если все, что вы хотите - это приблизительно знать, что делает эта строка кода, то это можно увидеть сразу.

Во всяком случае, вам следует по меньшей мере зрительно воспринимать FORMAT , а еще лучше разобраться, на что она способна, прежде, чем вы примкнете к про- или анти- FORMAT 'овскому лагерю. Также важно понимать по меньшей мере основы FORMAT , поскольку другие стандартные функции, такие, как функции выбрасывания условий, обсуждаемые в следующей главе, используют управляющие строки в стиле FORMAT для формирования вывода.

Чтобы сильнее запутать дело, FORMAT поддерживает три совершенно разных вида форматирования: печать таблиц с данными, структурная печать ( pretty printing ) s-выражений, и формирование удобочитаемых сообщений со вставленными FIXME (interpolated? вложенными?) значениями. Печать таблиц с текстовыми данными на сегодня несколько устарела; это одно из напоминаний, что Lisp стар, как FORTRAN . В действительности, некоторые директивы, которые вы можете использовать для печати значений с плавающей точкой внутри полей с фиксированной длинной были основаны прямо на edit descriptors FIXME (дескрипторах редактирования?) FORTRAN, которые использовались в FORTRAN для чтения и печати столбцов с данными, расположенными внутри полей с фиксированной длинной. Тем не менее, использование Common Lisp как замены FORTRAN выходят за рамки этой книги, так что я не буду обсуждать эти аспекты FORMAT .

Структурная печать также находится за рамками этой книги - не потому, что она устарела, а потому, что это слишком большая тема. Вкратце, механизм структурной печати Common Lisp - это настраиваемая система для печати блочно-структурированных данных, включая, но не ограничиваясь, s-выражениями, применяющаяся, когда необходимы переменные отступы и динамически увеличивающиеся переводы строк. Это отличный инструмент при необходимости, но не часто требуемый в повседневном программировании. [205]

Вместо этого я сосредоточусь на частях FORMAT , которые вы можете использовать, чтобы формировать удобочитаемые строки со вставленными в них значениями. Даже ограничивая обзор таким образом, остается изрядное количество материала. Не чувствуйте себя так, как будто вы обязаны помнить каждую деталь, описанную в этой главе. Вы можете достичь многого с лишь несколькими идиомами FORMAT . Сначала я опишу наиболее важные возможности FORMAT ; а вам решать, насколько вы хотите стать волшебником FORMAT .

<p>Функция FORMAT</p>

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

Первым аргументом FORMAT , получателем для печатаемого текста, может быть T , NIL , поток, или строка с указателем заполнения. Т обозначает поток *STANDARD-OUTPUT* , в то время как NIL заставляет FORMAT сформировать свой вывод в виде строки, которую функция затем возвращает [206] Если получатель - поток, то вывод пишется в поток. А если получатель - строка с указателем заполнения, то форматированный вывод добавляется к концу строки и указатель заполнения соответственно выравнивается. За исключением случая, когда получатель - NIL и функция возвращает строку, FORMAT возвращает NIL .

Второй аргумент - управляющая строка, является, в сущности, программой на языке FORMAT . Язык FORMAT не целиком "лисповый" - его основной синтаксис основан на символах, а не на s-выражениях, и оптимизирован для краткости, а не для легкого понимания. Вот почему сложные управляющие строки FORMAT могут быть приняты за помехи.

Большинство из директив FORMAT просто вставляют аргумент внутрь выводимого текста в той или иной форме. Некоторые директивы, такие как ~% , которая заставляет FORMAT выполнить перевод строки, не используют никаких аргументов. Другие, как вы увидите, могут использовать более одного аргумента. Одна из директив даже позволяет вам прыгать по списку аргументов, с целью обработки одного и того же аргумента несколько раз, или в некоторых ситуациях пропустить определенные аргументы. Но прежде, чем я буду обсуждать конкретные директивы, давайте взглянем на общий синтаксис директив.

<p>Директивы FORMAT</p>

Все директивы начинаются с тильды ( ~ ) и кончаются отдельным знаком, который идентифицирует директиву. Вы можете писать этот символ как в верхнем, так и в нижнем регистре. Некоторые директивы принимают префиксные параметры , которые пишутся непосредственно после тильды, разделяются запятыми, и используются для управления такими деталями, как - сколько разрядов печатать после десятичной точки при печати числа с плавающей точкой. Например, директива ~$ , одна из директив, использующихся для печати значений c плавающей точкой, по умолчанию печатает два разряда, следующие за десятичной точкой.

CL-USER> (format t "~$" pi)

3.14

NIL

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

CL-USER> (format t "~5$" pi)

3.14159

NIL

Значениями префиксного параметра являются либо числа, записанные как десятичные, или знаки, записанные в виде одинарной кавычки, за которой следует нужный символ. Значение префиксного параметра может быть также получено из аргумента формата двумя способами: префиксный параметр v заставляет FORMAT использовать один аргумент формата и назначить его значение префиксному параметру. Префиксный параметр # будет вычислен как количество оставшихся аргументов формата. Например:

CL-USER> (format t "~v$" 3 pi)

3.142

NIL

CL-USER> (format t "~#$" pi)

3.1

NIL

Я дам более правдоподобные примеры использования аргумента # в разделе "Условное форматирование".

Вы можете также опустить оба префиксных параметра. Впрочем, если вы хотите указать один параметр, но не желаете указывать параметры, стоящие перед ним, то вы должны включить запятую для каждого пропущенного параметра. Например, директива ~F , другая директива для печати значений с плавающей точкой, также принимает параметр для управления количеством десятичных разрядов при печати, но это второй по счету параметр, а не первый. Если вы хотите использовать ~F для печати числа с пятью десятичными разрядами, вы можете написать так:

CL-USER> (format t "~,5f" pi)

3.14159

NIL

Также вы можете изменить поведение некоторых директив при помощи модификаторов двоеточие и знака @ , которые ставятся после любого префиксного параметра и до идентифицирующего директиву знака. Эти модификаторы незначительно меняют поведение директивы. Например, с модификатором двоеточие , директива ~D , использующаяся для вывода целых чисел в десятичном виде, создает число с запятыми, разделяющими каждые три разряда, в то время как знак @ заставляет ~D включить знак плюс в случае положительного числа.

CL-USER> (format t "~d" 1000000)

1000000

NIL

CL-USER> (format t "~:d" 1000000)

1,000,000

NIL

CL-USER> (format t "~@d" 1000000)

+1000000

NIL

В случае необходимости вы можете объединить модификаторы двоеточие и @ , для того чтобы получить оба варианта:

CL-USER> (format t "~:@d" 1000000)

+1,000,000

NIL

В директивах, где оба модифицированных варианта поведения не могут быть осмысленно объединены, использование обоих модификаторов либо не определено или приобретает третье значение.

<p>Основы Форматирования</p>

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

Наиболее универсальная директива - ~A , которая использует один аргумент формата любого типа и печатает его в эстетичной (удобочитаемой) форме. Например, строки печатаются без кавычек и экранирующих символов (escape characters), а числа печатаются в форме, принятой для соответствующего числового типа. Если вы хотите просто получить значение, предназначенное для прочтения человеком, то эта директива - ваш выбор.

(format nil "The value is: ~a" 10) ==> "The value is: 10"

(format nil "The value is: ~a" "foo") ==> "The value is: foo"

(format nil "The value is: ~a" (list 1 2 3)) ==> "The value is: (1 2 3)"

Родственная директива, ~S , также требует один аргумент формата любого типа, и печатает его. Однако ~S пытается сформировать такой вывод, который мог бы быть прочитан обратно с помощью READ . Поэтому, строки должны быть заключены в кавычки, при необходимости знаки должны быть пакетно-специлизированы FIXME (package-qualified?), и так далее. Объекты, которые не имеют подходящего для READ представления печатаются в нечитаемом синтаксисе объектов FIXME, #<> . С модификатором двоеточие , обе директивы ~A и ~S порождают NIL в виде () , а не как NIL . Обе директивы ~A и ~S также принимают до четырех префиксных параметров, которые могут быть использованы для выравнивания пробелами FIXME (padding?), добавляемыми после (или до, при модификаторе @ ) значения, впрочем эти функции действительно полезны лишь при формировании табличных данных.

Две другие часто используемые директивы - это ~% , которая создает новую строку, и ~& , которая выполняет перевод строки . Разница между ними в том, что ~% всегда создает новую строку, тогда как ~& срабатывает только если она уже не находится в начале строки. Это удобно при создании слабо связанных функций, каждая из которых формирует кусок текста, и их нужно объединить различными способами. Например, если одна из функции выводит текст, который кончается новой строкой ( ~% ), а другая функция выводит некоторый текст, который начинается с перевода строки ( ~& ), то вам не стоит беспокоиться о получении дополнительной пустой строки, если вы вызываете их одну за другой. Обе эти директивы могут принимать единственный префиксный параметр, который обозначает количество выводимых новых строк. Директива ~% просто выведет заданное количество знаков новой строки, в то время как директива ~& создаст либо n - 1 либо n новых строк, в зависимости от того, начинает ли она с начала строки.

Реже используется родственная им директива ~~ , которая заставляет FORMAT вывести знак тильды. Подобно директивам ~% и ~& , она может быть параметризована числом, которое задает количество выводимых тильд.

<p>Знаковые и Целочисленные директивы</p>

Вдобавок к директивам общего назначения, ~A и ~S , FORMAT поддерживает несколько директив, которые могут использоваться для получения значений определенных типов особыми способами. Простейшая из них является директива ~C , которая используется для вывода знаков. Ей не требуются префиксные аргументы, но ее работу можно корректировать с помощью модификаторов двоеточие и @ . Без модификаций ее поведение на отличается от поведения ~A , за исключением того, что она работает только со знаками. Модифицированные версии более полезны. С модификатором двоеточие , ~:C выводит заданные по имени непечатаемые знаки, такие как пробел, символ табуляции и перевод строки. Это полезно, если вы хотите создать сообщение к пользователю, в котором упоминается некоторый символ. Например, следующий код:

(format t "Syntax error. Unexpected character: ~:c" char)

может напечатать такое сообщения:

Syntax error. Unexpected character: a

а еще вот такое:

Syntax error. Unexpected character: Space

Вместе с модификатором @ , ~@С выведет знак в синтаксисе знаков Lisp:

CL-USER> (format t "~@c~%" #\a)

#\a

NIL

Одновременно с модификаторами двоеточие и @ , директива ~C может напечатать дополнительную информацию о том, как ввести символ с клавиатуры, если для этого требуется специальная клавиатурная комбинация. Например, на Macintosh, в некоторых приложениях вы можете ввести нулевой символ (код символа 0 в ASCII или в любом надмножестве ASCII, наподобие ISO-8859-1 или Unicode) удерживая клавиши Control и нажав ' @ '. В OpenMCL, если вы напечатаете нулевой символ c помощью директивы ~:C , то она сообщит вам следующее:

(format nil "~:@c" (code-char 0)) ==> "^@ (Control @)"

Однако не все версии Lisp реализуют этот аспект директивы ~C . Даже если они это делают, то реализация может и не быть аккуратной - например, если вы запустите OpenMCL в SLIME, комбинация клавиш C-@ перехватывается Emacs, вызывая команду set-mark-command [207]

Другой важной категорией являются директивы формата, предназначенные для вывода чисел. Хотя для этого вы можете использовать директивы ~A и ~S , но если вы хотите получить над печатью чисел больший контроль, вам следует использовать одну из специально предназначенных для чисел директив. Ориентированные на числа директивы могут быть подразделены на две категории: директивы для форматирования целых чисел и директивы для форматирования чисел с плавающей точкой.

Вот пять родственных директив для форматированного вывода целых чисел: ~D , ~X , ~O , ~B , и ~R . Чаще всего применяется директива ~D , которая выводит целые числа по основанию 10 .

(format nil "~d" 1000000) ==> "1000000"

Как я упоминал ранее, с модификатором двоеточие она добавляет запятые.

(format nil "~:d" 1000000) ==> "1,000,000"

А с модификатором @ , она всегда будет печатать знак.

(format nil "~@d" 1000000) ==> "+1000000"

И оба модификатора могут быть объединены.

(format nil "~:@d" 1000000) ==> "+1,000,000"

С помощью первого префиксного параметра можно задать минимальный размер вывода, а с помощью второго - используемый символ заполнения. По умолчанию символ заполнения - это пробел, и заполнение всегда помещается до самого числа.

(format nil "~12d" 1000000) ==> " 1000000"

(format nil "~12,'0d" 1000000) ==> "000001000000"

Эти параметры удобны для форматирования объектов наподобие календарных дат в формате с фиксированной длинной.

(format nil "~4,'0d-~2,'0d-~2,'0d" 2005 6 10) ==> "2005-06-10"

Третий и четвертый параметры используются в связке с модификатором двоеточие : третий параметр определяет знак, используемый в качестве разделителя между группами и разрядами, а четвертый параметр определяет число разрядов в группе. Их значения по умолчанию - запятая и число 3 соответственно. Таким образом, вы можете использовать директиву ~:D без параметров для вывода больших чисел в стандартном для Соединенных Штатов формате, но можете заменить запятую на точку и группировку с 3 на 4 с помощью ~,,'.,4D .

(format nil "~:d" 100000000) ==> "100,000,000"

(format nil "~,,'.,4:d" 100000000) ==> "1.0000.0000"

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

Директивы ~X , ~O , и ~B работают подобно директиве ~D , за исключением того, что они выводят числа в шестнадцатеричном, восьмеричном, и двоичном виде.

(format nil "~x" 1000000) ==> "f4240"

(format nil "~o" 1000000) ==> "3641100"

(format nil "~b" 1000000) ==> "11110100001001000000"

Наконец, директива ~R - универсальная директива для задания системы счисления . Ее первый параметр - число между 2 и 36 (включительно), которое обозначает, какое основание системы счисления использовать. Оставшиеся параметры такие же, что и четыре параметра, принимаемые директивами ~D , ~X , ~O , и ~B , а модификаторы двоеточие и @ меняют ее поведение схожим образом. Кроме того, директива ~R ведет себя особым образом при использовании без префиксных параметров, который я буду обсуждать в разделе "Директивы для Английского языка".

<p>Директивы для Чисел с Плавающей Точкой</p>

Вот четыре директивы для форматирования значений с плавающей точкой: ~F , ~E , ~G и ~$ . Первые три из них - это директивы, основанные на дескрипторах редактирования FIXME (edit descriptor?) FORTRAN. Я пропущу большинство деталей этих директив, поскольку они в основном имеют дело с форматированием чисел с плавающей точкой для использования в табличной форме. Тем не менее вы можете использовать директивы ~F , ~E , и ~$ для вставки значений с плавающей точкой в текст. С другой стороны директива ~G , или генерал , FIXME (генерал?) сочетает аспекты директив ~F и ~E единственным осмысленным способом для печати таблиц.

Директива ~F печатает свой аргумент, который должен быть числом [208], в десятичном формате, по возможности контролируя количество разрядов после десятичной точки. Директиве ~F , тем не менее, разрешается использовать компьютеризированную научную нотацию FIXME (компьютеризированное экспоненциальное представление?), если число достаточно велико либо мало. Директива ~E , с другой стороны, всегда выводит числа в компьютеризированной научной нотации. Обе эти директивы принимают несколько префиксных параметров, но вам нужно беспокоиться только о втором, который управляет количеством разрядов, печатаемых после десятичной точки.

(format nil "~f" pi) ==> "3.141592653589793d0"

(format nil "~,4f" pi) ==> "3.1416"

(format nil "~e" pi) ==> "3.141592653589793d+0"

(format nil "~,4e" pi) ==> "3.1416d+0"

Директива ~$ (значок доллара) похожа на ~F , но несколько проще. Как подсказывает ее имя, она предназначена для вывода денежных единиц. Без параметров она эквивалентна ~,2F . Чтобы изменить количество разрядов, печатаемых после десятичной точки, используйте первый параметр, в то время как второй параметр регулирует минимальное количество разрядов, печатающихся до десятичной дочки.

(format nil "~$" pi) ==> "3.14"

(format nil "~2,4$" pi) ==> "0003.14"

С модификатором @ все три директивы, ~F , ~E и ~$ можно заставить всегда печатать знак, плюс или минус [209].

<p>Директивы для Английского языка</p>

Некоторые из удобнейших директив FORMAT для формирования удобочитаемых сообщений - те, которые выводят английский текст. Эти директивы позволяют вам выводить числа как английский текст, выводить слова в множественном числе в зависимости от значения аргумента формата, и менять регистр FIXME (case conversion? выполнять преобразование между строчными и прописными буквами?) в секциях вывода FORMAT .

Директива ~R , которую я обсуждал в разделе "Знаковые и Целочисленные директивы", при использовании без указания системы счисления, печатает числа как английские слова или римские цифры. При использовании без префиксного параметра и модификоторов, она выводит число словами как количественное числительное.

(format nil "~r" 1234) ==> "one thousand two hundred thirty-four"

С модификатором двоеточие она выводит число как порядковое числительное.

(format nil "~:r" 1234) ==> "one thousand two hundred thirty-fourth"

И вместе с модификатором @ , она выводит число в виде римских цифр; вместе с @ и двоеточием она выводит римские цифры, в которых четверки и девятки записаны как IIII и VIIII вместо IV и IX .

(format nil "~@r" 1234) ==> "MCCXXXIV"

(format nil "~:@r" 1234) ==> "MCCXXXIIII"

Для чисел, слишком больших, чтобы быть представленными в заданной форме, ~R ведет себя как ~D .

Чтобы помочь вам формировать сообщений со словами в нужном числе, FORMAT предоставляет директиву ~P , которая просто выводит 's' если соответствующий аргумент не 1 .

(format nil "file~p" 1) ==> "file"

(format nil "file~p" 10) ==> "files"

(format nil "file~p" 0) ==> "files"

Тем не меннее обычно вы будете использовать ~P вместе с модификатором двоеточие , который заставляет ее повторно обработать предыдущий аргумент формата.

(format nil "~r file~:p" 1) ==> "one file"

(format nil "~r file~:p" 10) ==> "ten files"

(format nil "~r file~:p" 0) ==> "zero files"

С модификатором @ , который может быть объединен с модификатором двоеточие , ~P выводит y или ies .

(format nil "~r famil~:@p" 1) ==> "one family"

(format nil "~r famil~:@p" 10) ==> "ten families"

(format nil "~r famil~:@p" 0) ==> "zero families"

Очевидно, что ~P не может разрешить все проблемы образования множественного числа и не может помочь при формировании сообщений на других языках (отличных от английского), она удобна в тех ситуациях, для которых предназначена. А директива ~[ , о которой я расскажу очень скоро, предоставит вам более гибкий способ параметризации вывода FORMAT .

Последняя директива, посвященная выводу английского текста - это ~( , которая позволяет вам контролировать регистр выводимого текста. Каждая ~( составляет пару с ~) , и весь вывод, сгенерированный частью управляющей строки между двумя маркерами будет преобразован в нижний регистр.

(format nil "~(~a~)" "FOO") ==> "foo"

(format nil "~(~@r~)" 124) ==> "cxxiv

Вы можете изменить поведение ~( модификатором @ , чтобы заставить ее начать с прописной буквы первое слово на участке текста, с двоеточием заставить ее печатать все слова с прописной буквы, а с обоими модификаторами - преобразовать весь текст в верхний регистр. (Подходящие для этой директивы слова представляют собой буквенно-цифровые знаки, ограниченные не-буквенно-цифровыми знаками или концом текста.)

(format nil "~(~a~)" "tHe Quick BROWN foX") ==> "the quick brown fox"

(format nil "~@(~a~)" "tHe Quick BROWN foX") ==> "The quick brown fox"

(format nil "~:(~a~)"p "tHe Quick BROWN foX") ==> "The Quick Brown Fox"

(format nil "~:@(~a~)" "tHe Quick BROWN foX") ==> "THE QUICK BROWN FOX"

<p>Условное Форматирование</p>

Вдобавок к директивам, вставляющим в выводимый текст свои аргументы и видоизменяющими прочий вывод, FORMAT предоставляет несколько директив, который реализуют простые управляющие структуры внутри управляющих строк. Одна из них, которую вы использовали в главе 9, это условная директива ~[ . Эта директива замыкается соответствующей директивой ~] , а между ними находятся выражения, разделенные ~; . Работа директивы ~[ - выбрать одно из выражений, которое затем обрабатывается в FORMAT . Без модификаторов или параметров, выражение выбирается по числовому индексу; директива ~[ использует аргумент формата, который должен быть числом, и выбирает N -ное (считая от нуля) выражение, где N - значение аргумента.

(format nil "~[cero~;uno~;dos~]" 0) ==> "cero"

(format nil "~[cero~;uno~;dos~]" 1) ==> "uno"

(format nil "~[cero~;uno~;dos~]" 2) ==> "dos"

Если значение аргумента больше ,чем число выражений, то ничего не печатается.

(format nil "~[cero~;uno~;dos~]" 3) ==> ""

Однако если последний разделитель выражений это ~:; вместо ~;, тогда последнее выражение служит выражением по умолчанию.

(format nil "~[cero~;uno~;dos~:;mucho~]" 3) ==> "mucho"

(format nil "~[cero~;uno~;dos~:;mucho~]" 100) ==> "mucho"

Также возможно определить выбранное выражение, используя префиксный параметр. Хотя было бы глупо использовать константу, записанную прямо в управляющей строке, вспомним, что знак '#', используемый в качестве префиксного параметра означает число оставшихся для обработки аргументов. Поэтому вы можете определить строку формата следующим образом:

(defparameter *list-etc*

"~#[NONE~;~a~;~a and ~a~:;~a, ~a~]~#[~; and ~a~:;, ~a, etc~].")

и использовать ее так:

(format nil *list-etc*) ==> "NONE."

(format nil *list-etc* 'a) ==> "A."

(format nil *list-etc* 'a 'b) ==> "A and B."

(format nil *list-etc* 'a 'b 'c) ==> "A, B and C."

(format nil *list-etc* 'a 'b 'c 'd) ==> "A, B, C, etc."

(format nil *list-etc* 'a 'b 'c 'd 'e) ==> "A, B, C, etc."

Заметим, что управляющая строка в действительности содержит две ~[~] директивы - обе из которых применяют # для выбора используемого выражения. Первая директива использует от нуля до двух аргументов, тогда как вторая использует еще один, если он есть. FORMAT молча проигнорирует любые аргументы, сверх использованных во время обработки управляющей строки.

С модификатором двоеточие ~[ может содержать только два выражения; директива использует единственный аргумент и обрабатывает первое выражение, если аргумент NIL , и второе выражение в противном случае. Вы уже использовали этот вариант ~[ в главе 9 для формирования сообщений типа принять/отклонить FIXME (pass/fail message? сработало или не сработало?), таких как это:

(format t "~:[FAIL~;pass~]" test-result)

Заметим, что оба выражения могут быть пустыми, но директива должна содержать ~; .

Наконец, с модификатором @ , директива ~[ может иметь только одно выражение. Директива использует первый аргумент и если он отличен от NIL , обрабатывает выражение, при этом список аргументов восстанавливается заново, чтобы первый аргумент был доступен для использования заново.

(format nil "~@[x = ~a ~]~@[y = ~a~]" 10 20) ==> "x = 10 y = 20"

(format nil "~@[x = ~a ~]~@[y = ~a~]" 10 nil) ==> "x = 10 "

(format nil "~@[x = ~a ~]~@[y = ~a~]" nil 20) ==> "y = 20"

(format nil "~@[x = ~a ~]~@[y = ~a~]" nil nil) ==> ""

<p>Итерация</p>

Другая директива FORMAT , мимоходом виденная вами, это директива итерации ~{ . Эта директива сообщает FORMAT перебрать элементы списка или неявного списка аргументов формата.

Без модификаторов, ~{ принимает один аргумент формата, который должен являться списком. Подобно директиве ~[ , которой всегда соответствует директива ~] , директива ~{ всегда имеет соответствующую замыкающую ~} . Текст между двумя маркерами обрабатывается как управляющая строка, которая выбирает свой аргумент из списка, поглощенного FIXME (consumed?) директивой ~{ . FORMAT будет циклически обрабатывать эту управляющую строку до тех пор, пока в перебираемом списке не останется элементов. В следующем примере, ~{ принимает один аргумент формата, список (1 2 3) , и затем обрабатывает управляющую строку "~a, ", повторяя, пока все элементы списка не будут использованы.

(format nil "~{~a, ~}" (list 1 2 3)) ==> "1, 2, 3, "

При этом раздражает, что при печати за последним элементом списка следует запятая и пробел. Вы можете исправить это директивой ~^ ; внутри тела ~{ директива ~^ заставляет итерацию немедленно остановиться, и если в списке не остается больше элементов, прервать обработку оставшейся части управляющей строки. Таким образом, для предотвращения печати запятой и пробела после последнего элемента в списке, вы можете предварить его ~^ .

(format nil "~{~a~^, ~}" (list 1 2 3)) ==> "1, 2, 3"

После первых двух итераций, при обработке ~^ , в списке остаются необработанные элементы. При этом на третий раз, после того как директива ~a обработает 3 , ~^ заставит FORMAT прервать итерацию без печати запятой и пробела.

С модификатором @ , ~{ обработает оставшийся аргумент формата как список.

(format nil "~@{~a~^, ~}" 1 2 3) ==> "1, 2, 3"

Внутри тела ~{...~} специальный префиксный параметр # ссылается на число необработанных элементов списка, а не на число оставшихся элементов формата. Вы можете использовать его вместе с директивой ~[ для печати разделенного запятыми списка с "and" перед последним элементом, вот так:

(format nil "~{~a~#[~;, and ~:;, ~]~}" (list 1 2 3)) ==> "1, 2, and 3"

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

(format nil "~{~a~#[~;, and ~:;, ~]~}" (list 1 2)) ==> "1, and 2"

Вы можете поправить это кучей способов. Следующий пользуется эффектом, который дает директива ~@{, когда она заключена внутри другой директивы ~{ или ~@{ - в этом случае она перебирает все элементы, оставшиеся в обрабатываемом внешней директивой ~{ списке. Вы можете объединить ее с директивой ~#[ чтобы следующая управляющая строка форматировала список в соответствии с английской грамматикой:

(defparameter *english-list*

"~{~#[~;~a~;~a and ~a~:;~@{~a~#[~;, and ~:;, ~]~}~]~}")

(format nil *english-list* '()) ==> ""

(format nil *english-list* '(1)) ==> "1"

(format nil *english-list* '(1 2)) ==> "1 and 2"

(format nil *english-list* '(1 2 3)) ==> "1, 2, and 3"

(format nil *english-list* '(1 2 3 4)) ==> "1, 2, 3, and 4"

В то время, как эта управляющая строка приближается к такому классу кода, который трудно понять, после того как он написан FIXME (write-only code?), все-таки это возможно, когда у вас есть немного времени. Внешние ~{...~} принимают список и затем перебирают его. Затем все тело цикла состоит из ~#[...~]; печать производится на каждом шаге цикла, и таким образом зависит от количества обрабатываемых элементов, оставшихся в списке. Разделяя директиву ~#[...~] разделителями выражений ~; вы можете увидеть, что она состоит из четырех выражений, последнее из которых является выражением по умолчанию, поскольку его предваряет ~:; в отличие от простой ~; . Первое выражение, выполняющееся при нулевом числе элементов, пусто, оно нужно только в том случае, если обрабатываемых элементов больше не осталось, тогда мы должны остановиться. Второе выражение с помощью простой директивы ~a обрабатывает случай, когда элемент единственный. С двумя элементами справляется "~a and ~a". И выражение по умолчанию, которое имеет дело с тремя и более элементами, состоит из другой директивы итерации, в этот раз используется ~@{ для перебора оставшихся элементов списка, обрабатываемых внешней ~{ . В итоге тело цикла представляет собой управляющую строку, которая может корректно обработать список трех или более элементов, что в данной ситуации более чем достаточно. Поскольку цикл ~@{ использует все оставшиеся элементы списка, внешний цикл выполняется только один раз.

Если вы хотите напечатать что-нибудь особенное, например "<empty>", когда список пуст, то у вас есть пара способов это сделать. Возможно проще всего будет вставить нужный вам текст в первое (нулевое) выражение внешней ~#[ и затем добавить модификатор двоеточие к замыкающей ~} внешнего цикла - двоеточие заставит цикл выполниться по меньшей мере один раз, даже если список пуст, и в этот момент FORMAT обработает нулевое выражение условной директивы.

(defparameter *english-list*

"~{~#[<empty>~;~a~;~a and ~a~:;~@{~a~#[~;, and ~:;, ~]~}~]~:}")

(format nil *english-list* '()) ==> "<empty>"

Удивительно, что директива ~{ предоставляет даже больше вариантов с различными комбинациями префиксных параметров и модификаторов.