Интерфейсы
И до того, как буду строить стену, я должен знать,
Что огораживаю я: внутри или снаружи, -
И что не нарушаю чьих-то прав.
Есть нечто, что не терпит ограждений,
Ломая ихРоберт Фрост. Починка стены (Перевод В. Кириллова)
Суть проектирования — сбалансировать конфликтующие цели и ограничения. Когда вы пишете небольшую программу для собственного пользования, вы, конечна, можете сами выбирать конкретные решения, этот выбор не затронет ничего и никого, кроме вас. Но если ваш код будет использоваться кем-то еще, каждое решение имеет более широкие последствия.
Среди проблем, которые надо решить при проектировании, стоит выделить следующие.
Интерфейсы: какой доступ и какой сервис предлагается? Интерфейс, в сущности, является соглашением между поставщиком (программистом) и потребителем. В идеале мы должны предоставлять унифицированные и удобные средства, имеющие достаточно возможностей для того, чтобы их было легко использовать, и в то же время не настолько большие, чтобы стать громоздкими. Сокрытие информации: какая информация доступна, а какая — нет? Интерфейс должен предоставлять прямой доступ к компонентам, скрывая при этом детали реализации, — с тем чтобы их можно было изменять, не затрагивая пользователя. Управление ресурсами: кто отвечает за управление памятью и другими ограниченными ресурсами? Здесь главными проблемами являются выделение и освобождение памяти и управление совместно используемой информацией. Обработка ошибок: кто обнаруживает ошибки, кто сообщает о них| и каким образом все это делается? Какие попытки восстановления предпринимаются при обнаружении ошибки?В главе 2 мы рассмотрели составные части, из которых строится система, — структуры данных. В главе 3 мы узнали, как объединять их в небольшие программы. Теперь наше внимание сосредоточится на интерфейсах между компонентами, получаемыми из разных источников. В этой главе мы проиллюстрируем проектирование интерфейсов созданием библиотеки функций и структур данных для решения одной хорошо известной задачи. Попутно мы познакомим вас с некоторыми принципами проектирования. Как правило, при проектировании приходится принимать огромное количество решений, но большинство из них делается почти бессознательно. Из-за незнания базовых принципов и возникают те малопонятные интерфейсы, которые ежедневно так досаждают программистам.
Значения, разделенные запятой
Значения, разделенные запятой (Comma-Separated Values — CSV), — так называется естественный и широко распространенный способ представления табличных данных. Каждая строка таблицы соответствует строке текста; поля в строке разделены запятыми. Таблица из главы 3, представленная в формате CSV, начиналась бы так:
Этот формат используется для чтения и записи различными программами, работающими с электронными таблицами. Используется он и на некоторых Web-страницах, например для выдачи справок о биржевых котировках. Одна из популярных страниц с биржевыми курсами представляет информацию примерно так:
Биржевой символ | Последние торги | Изменения | Объем | ||
LU | 2:19РМ | 86-1/4 | +4-1/16 | +4,94 % | 5 804 800 |
Т | 2:19РМ | 60-11/16 | -1-3/16 | -1,92% | 2468000 |
MSFT | 2:24РМ | 106-9/16 | +1-3/8 | + 1,31 % | 11474900 |
Загружаемый табличный формат
Получать значения с помощью Web-браузера удобно, но долго. Вы запускаете браузер, ждете, потом на вас вываливается поток рекламы, вы выбираете список котировок, опять ждете, ждете, ждете, опять лицезрее-те рекламу и т. д. — и все для того, чтобы получить несколько чисел. Для дальнейшей обработки значений вам придется повторить весь процесс еще не один раз, а между тем, выбрав ссылку "Download Spreadsheet Format" (скачать в табличном формате), вы сразу получите файл, содержащий в основном ту же самую информацию в виде данных в формате CSV — примерно такого вида (здесь строки откорректированы нами по длине):
Сразу ясно, что второй способ проще: за вас работает компьютер. Браузеры
позволяют вашему компьютеру получать доступ к данным с удаленного сервера,
но гораздо лучше получать данные без необходимости муторного личного участия.
Надо отметить, что на самом деле все нажимания на кнопки — не более чем
некая текстовая процедура: браузер читает некий HTML, вы вводите некий
текст, и браузер отсылает его на сервер, получая в ответ какой-то новый
HTML. Имея нормальные инструменты и язык программирования, нетрудно добиться
получения информации в автоматическом режиме. Вот текст программы на языке
Tel, обращающейся к Web-сайту биржевых курсов и возвращающей данные в
формате CSV, предваренные несколькими заголовочными строками:
Таинственная последовательность f =. . ., следующая за аббревиатурами
биржевых сводок, — недокументированная управляющая строка (аналог первого
аргумента printf), определяющая, какие данные требуется получить. Экспериментальным
путем мы выяснили, что s задает код акций, 11 — последнюю цену, d — изменение
цены по сравнению со вче-' рашним днем и т. п. Важны здесь не конкретные
детали, которые могут всячески меняться, а открывающаяся возможность автоматизации
получения нужной информации и преобразования ее в нужный вид без участия
пользователя. Пусть работает железный агрегат.
Для того чтобы запустить getquotes, вам потребуются какие-то доли секунды, — это несравненно быстрее, чем возиться с браузером.
Получив данные, мы, естественно, захотим подвергнуть их дальнейшей обработке. С форматами данных вроде CSV лучше всего работать, если есть удобные библиотеки, осуществляющие преобразования из формата в формат и, возможно, соединенные с вспомогательными операциями типа преобразования чисел. Однако мы не знаем ни одной доступной бесплатной библиотеки для обработки CSV, поэтому напишем свою.
В нескольких последующих разделах мы создадим три версии библиотеки для чтения и преобразования данных CSV. Попутно мы обсу-! дим аспекты, неизбежно возникающие при проектировании программ, взаимодействующих с другими программами. Так, например, оказалось, что стандартного определения CSV не существует, поэтому наша реализация не может базироваться на точной спецификации, — это обычная ситуация при проектировании интерфейсов.
Прототип библиотеки
Вряд ли нам удастся получить удачный проект библиотеки интерфейса с первой попытки. Как написал однажды Фред Брукс (Fred Brooks), "заранее планируйте выкинуть первую версию — все равно придется". Брукс писал о больших системах, но суть остается той же и для любой нормальной программы. Как правило, до тех пор пока вы не создали первой версии и не поработали с ней, трудно представить себе все аспекты работы программы настолько хорошо, чтобы спроектировать достойный продукт.
Исходя из этих соображений, мы начнем создавать библиотеку CSV с версии "на выброс", с прототипа. В первой версии мы проигнорируем многие проблемы, которые должны быть решены в грамотной библиотеке, однако она будет достаточно полной, чтобы ее можно было использовать, и с помощью этой версии мы поближе познакомимся с задачей.
Начнем с функции csvgetline, которая считывает одну строку данных CSV из файла в буфер, разделяет ее на поля массива, удаляет кавычки и возвращает количество полей. В течение многих лет мы уже не раз писали что-то подобное на различных языках, так что задание нам знакомо. Вот версия-прототип на С; мы пометили код вопросами, потому что это всего-навсего прототип:
Комментарий в начале функции включает в себя пример формата ввода, воспринимаемого
функцией; такие комментарии очень полезны в программах, разбирающих беспорядочный
ввод.
Формат CSV слишком сложен, чтобы разбирать его с помощью scanf, поэтому мы использовали функцию strtok из стандартной библиотеки С. Каждый вызов strtok(p, s) возвращает указатель на первую лексему (token) из строки р, состоящую из символов, не входящих в s; st rtok обрывает эту лексему, заменяя следующий символ исходной строки нулевым байтом. При первом вызове strtok первым аргументом является сканируемая строка; при следующих вызовах для обозначения того, что сканирование должно продолжиться с той точки, где закончился предыдущий вызов, в этом месте стоит NULL. Интерфейс получился убогим. Поскольку между вызовами strtok хранит переменную в некоем неизвестном месте, в каждый момент может исполняться только одна последовательность вызовов; несвязанные перемежающиеся вызовы будут конкурировать и мешать друг другу.
Использованная нами функция unquote удаляет открывающие и закрывающие кавычки, которые могут содержаться во вводимой строке. Она не обрабатывает, однако, вложенных кавычек, поэтому для прототипа ее еще можно использовать, но в общем случае она непригодна:
При выводе printf заключает поля в парные простые кавычки; это зрительно
разделяет поля и помогает выявить ошибки некорректной обработки пробелов.
Мы можем прогнать через этот тест результаты работы getquotes. tcl:
(Заголовки .HTTP мы убрали.)
Итак, у нас'есть прототип, который, кажется, в состоянии работать с данными вроде приведенных выше. Однако теперь было бы логично опробовать его на чем-то еще (особенно если мы планируем распространять эту библиотеку). Мы нашли еще один Web-сайт, позволяющий скачать биржевые котировки и получить файл с той же, собственно, информацией, но представленной в несколько иной форме: для разделения записей вместо символа йеревода строки используется символ возврата каретки (\г), а в конце файла завершающего возврата каретки нет. Выглядят новые данные так (мы отформатировали их, чтобы они умещались на странице):
При таком вводе наш прототип позорно провалился.
Мы спроектировали наш прототип, изучив только один источник данных, тестирование провели на данных из того же источника. Стало быть, нечего удивляться тому, что первое же столкновение с данными из другого источника привело к гибельным последствиям.
Длинные строки на вводе, большое количество полей, непредусмотренные или пропущенные разделители — все это вызывает проблемы. Наш ненадежный прототип подходит только для индивидуального использования или в целях демонстрации принципиальной пригодности выбранного подхода, но не более того. Что ж, пришло время переработать проект.
При создании прототипа мы сделали ряд предположений — явных и неявных. Ниже перечислены некоторые из наших решений, зачастую не самых подходящих для универсальной библиотеки. Каждое поднимает вопрос, требующий более тщательной проработки.
Прототип не способен обработать длинные строки или большое количество полей. Он может выдавать неправильные ответы или вообще зависать, потому что в нем нет даже проверки на переполнение, не говоря уже о возвращении каких-то разумных значений при возникновении ошибок. Предполагается, что ввод состоит из строк, оканчивающихся символом перевода строки. Поля разделены запятыми; если поле заключено в кавычки, последние удаляются. Не предусмотрен случай вложенных кавычек или запятых. Вводимая строка не сохраняется; в процессе генерации полей она переписывается. При переходе от одной строки к следующей никакие данные не сохраняются; если что-то надо запоминать, то следует создавать копию. Доступ к полям осуществляется через глобальную переменную — массив field, который используется совместно функцией csvget-line и функцией, которая ее вызвала; нет никакого контроля доступа к содержимому полей или указателям. Не предусмотрено никаких средств для предотвращения доступа за последнее поле. Использование глобальных переменных делает проект непригодным для многонитевой среды или даже для одновременного исполнения двух параллельных вызовов. Функция csvgetline читает только из уже открытых файлов; открытие лежит целиком на совести вызывающего кода. Ввод и разделение полей прочно связаны друг с другом: каждый вызов читает строку и сразу же разбивает ее на поля вне зависимости от того, есть ли в этом необходимость. Возвращаемое значение есть количество полей в строке; для подсчета этого значения строка должна быть разделена на поля. Не предусмотрено никакого механизма для определения ошибок конца файла. Нет никакой возможности изменить ни одно из перечисленных свойств, не внося изменений в код.В этом длинном, но далеко не полном списке приведены те решения, которые мы приняли на этапе проектирования, — и каждое решение навсегда вплетено в код. Это приемлемо для временной версии, разбирающей файлы известного формата, поступающие из одного конкретного источника. Но что будет, если формат изменится, или запятая появится внутри кавычек, или сервер выдаст длинную строку или много полей?
Может показаться, что со всем этим нетрудно справиться, ведь "библиотека" мала и, в конце концов, является всего лишь прототипом. Представьте, однако, что этот код, пролежав в забвении месяцы или годы, в какой-то момент станет частью большой программы, спецификации которой будут/ меняться. Как адаптируется csvgetline? Если программу будут использовать другие люди, то скороспелые решения в ее проектировании могут вызвать проблемы, которые проявятся через долгое время. К сожалению, история многих интерфейсов подтверждает это заявление: большое количество временного, чернового кода просачивается в большие программные системы, в которых этот код так и остается "грязным" и зачастую слишком медленным.
Библиотека для распространения
Теперь с учетом того, чему мы научились на опыте прототипа, попробуем создать библиотеку общего назначения. Наиболее очевидные требования такие: csvgetline должна быть более устойчива, то есть уметь обрабатывать и длинные строки, и большое количество полей; более осторожно надо подойти и к синтаксическому разбору полей.
Для создания интерфейса, который могли бы использовать и другие люди, мы должны выработать решения по аспектам, перечисленным в начале главы: интерфейсы, сокрытие деталей, управление ресурсами и обработка ошибок; их взаимосвязь оказывает сильнейшее влияние на проект. Наше разделение этих проблем было несколько произвольно, так как они сильно взаимосвязаны.
Интерфейс. Мы выработали решение о трех базовых операциях:
char *csvgetline(FILE *): читает новую CSV строку;
char *csvf ield(int n): возвращает n-е поле текущей строки;
int csvnf leld(void): возвращает число полей в текущей строке.
Какое значение должна возвращать csvgetline? Желательно, чтобы она возвращала
побольше полезной информации; тогда напрашиваете* возвращение того же
количества полей, как и в прототипе. Но тогда количество полей будет подсчитываться
даже в случае, если поля эти больше использоваться не будут. Еще один
вариант возврата — длина вводимой строки, но это значение зависит от того,
включать ли в длину завершающий символ перевода строки. После ряда экспериментов
мы пришли к выводу, что csvgetline должна возвращать указатель на оригинальную
строку ввода или NULL, если был достигнут конец файла.
Мы будем удалять символ перевода строки из конца строки, возвращаемой csvgetline, так как, если понадобится, его можно без труда вписать обратно.
С определением поля дело обстоит довольно сложно; мы попробовали собрать воедино варианты, которые встречались в электронных таблицах и других программах. Получилось примерно следующее.
Поле является последовательностью из нуля или более символов. Поля разделены запятыми. Открывающие и завершающие пробелы (пропуски) сохраняются. Поле может быть заключено в двойные кавычки, в этом случае оно может содержать запятые. Поля, заключенные в двойные кавычки, могут содержать символы двойных кавычек, представляемые парой последовательных двойных кавычек, — то есть CSV поле "х""у" определяет строку х"у. Поля могут быть пустыми; поле, определяемое как "", считается пустым и эквивалентно полю, определяемому двумя смежными запятыми.
Поля нумеруются с нуля. Как быть, если пользователь запросит несуществующее поле, вызвав csvfield(-l) или csvfield( 100000)? Мы могли бы возвращать "" (пустую строку), поскольку это значение можно выводить или сравнивать; программам, которые работают с различным количеством полей, не пришлось бы принимать специальных предосторожностей на случай обращения к несуществующему полю. Однако этот способ не предоставляет возможности отличить пустое поле от несуществующего. Второй вариант — выводить сообщение об ошибке или даже прерывать работу; несколько позже мы объясним, почему так делать нежелательно. Мы решили возвращать NULL — общепринятое в С значение для несуществующей строки.
Сокрытие деталей. Библиотека не будет накладывать никаких ограничений ни на длину вводимой строки, ни на количество полей. Чтобы осуществить это, либо вызывающая сторона должна предоставить память, либо вызываемая сторона (то есть библиотека) должна ее зарезервировать. Посмотрим, как это организовано в сходных библиотеках: при вызове функции f gets ей передается массив и максимальный размер; если строка оказывается больше буфера, она разбивается на части. Для работы с CSV такое поведение абсолютно неприемлемо, поэтому наша библиотека будет сама выделять память по мере необходимости.
Только функция csvgetline занимается управлением памятью; вне ее ничего о методах организации памяти не известно. Лучше всего осуществлять такую изоляцию через интерфейс функции: получается (то есть видно снаружи), что csvgetline читает следующую строку — вне зависимости от ее размера, csvfield(n) возвращает указатель на байты п-го поля текущей строки, a csvnf ields возвращает количество полей в текущей строке.
Мы должны будем наращивать память по мере появления длинных строк или большого количества полей. Детали того, как это происходит, спрятаны в функциях csv; никакие другие части программы не знают, как это делается: использует ли библиотека маленькие массивы, наращивая их при необходимости, или, наоборот, очень большие массивы, или вообще какой-то совершенно другой подход. Точно так же интерфейс не раскрывает и того, когда же память высвобождается.
Если пользователь вызывает только csvgetline, то нет надобности разделять строку на поля; это можно сделать по специальному требованию. Происходит ли разделение полей ретиво (eager, непосредственно при чтении строки), лениво (lazy, только когда нужно посчитать количество полей) или очень ленива (very lazy, выделяется только запрошенное поле) — еще одна деталь реализации, скрытая от пользователя.
Управление ресурсами. Мы должны решить, кто отвечает за совместно используемую информацию.
Возвращает ли csvgetline исходные данные или делает копию? Мы решили, что csvgetline возвращает указатель на исходные данные, которые будут перезаписаны при чтении следующей строки. Поля будут созданы в копии введенной строки, и csvf ield будет возвращать указатель на поле в копии строки. При таких соглашениях пользователь должен сам создавать дополнительную копию, если какая-то конкретная строка или поле должны быть сохранены или изменены, и пользователь же отвечает за высвобождение этой памяти после того, как необходимость в ней отпадет.
Кто открывает и закрывает файл ввода? Кто бы ни открывал вводимый файл,
он же должен его закрыть; парные действия должны выполняться на одном
уровне или в одном и том же месте. Мы будем исходить из предположения,
что csvgetline вызывается с указателем FILE, определяющим уже открытый
файл; по окончании обработки файл будет закрыт вызывающей стороной.
Управление ресурсами, используемыми совместно или передающимися между
библиотекой и вызывающими ее программами, — сложная задача; часто существуют
веские, но противоречивые доводы в пользу различных решений. Ошибки и
недопонимание при разделении ответственности за управление совместно используемыми
ресурсами — характерный источник ошибок.
Обработка ошибок. Когда csvgetline возвращает NULL, не существует способа отличить выход на конец файла от ошибки вроде нехватки памяти; точно так же и доступ к несуществующему полю не вызовет ошиб: ки. По аналогии с terror мы могли бы добавить в интерфейс еще одну функцию, csvgete г го г, которая сообщала бы нам о последней ошибке, но для простоты мы не будем включать ее в данную версию.
Надо принять за постулат, что функции библиотеки не должны просто прерывать исполнение при возникновении ошибки; статус ошибки должен быть возвращен вызывающей стороне. Также не следует печатать сообщения или выводить окна диалога, поскольку функции библиотеки могут исполняться в системах, где такие сообщения будут мешать чему-то еще. Обработка ошибок — тема, достойная отдельного обсуждения, и мы еще вернемся к ней далее в этой главе.
Спецификация. Все описанные выше решения и допущения должны быть собраны воедино в спецификацию, описывающую средства, предоставляемые csvgetline, и методы ее использования. В больших проектах спецификация должна предшествовать реализации, при этом, как правило, разные люди и даже разные организации создают спецификацию и пишут код. Однако на практике эти работы часто производят параллельно — тогда спецификация и код эволюционируют совместно; иногда же "спецификация" пишется уже после разработки программы, чтобы приблизительно описать, что же делает код.
Самый грамотный подход — писать спецификацию как можно раньше и (как делали мы) пересматривать ее по мере реализации кода. Чем тщательнее и вдумчивее будет написана спецификация, тем больше вероятность создать хороший продукт. Даже при создании программ для собственного пользования важно подготовить достаточно осмысленную спецификацию, поскольку она требует анализа существующих проблем и четкого фиксирования принятых решений.
В нашем случае спецификация будет включать в себя прототипы функций и детальное описание их поведения, сделанных допущений и распределения ответственности:
Поля разделены запятыми.
Поле может быть заключено в двойные кавычки: "...".
Поле, заключенное в кавычки, может содержать
запятые,но не символы перевода строки.
Поле, заключенное в кавычки, может содержать символыдвойных кавычек, представляемые парой
двойных кавычек Поле может быть пустым;
"" и пустая строка равно представляют пустое
поле. Предваряющие и заключительные пробелы сохраняются.
char *csvgetline(FILE *f);
читает одну строку из файла ввода f;
подразумевается, что строки во вводе
оканчиваются символами \г, \п, \г\пилиЕОЕ.
возвращает указатель на строку
(символы конца строки
удаляются) или NULL, если достигнут EOF.
строки могут иметь произвольную длину; возвращается
NULL, если превышен резерв памяти, строки
рассматриваются как память,
доступная только для чтения;
для сохранения или изменения содержимого
вызывающая сторона должна сделать копию.
char *csvfield(int n);
поля нумеруются начиная с 0. возвращает n-е поле
из последней строки, прочитанной csvgetline;
возвращает NULL, если n отрицательно или лежит
за последним полем, поля разделяются запятыми.
поля могут быть заключены в двойные кавычки, эти кавычки убираются;внутри двойных кавычек
запятая не являетсяразделителем, а пара
символов "" заменяется на ". в полях, не ограниченных кавычками, кавычки
рассматриваютсякак обычные символы, может
быть произвольное количество полей любой
длины;возвращает NULL, если превышается
резерв памяти, поля рассматриваются как память, доступная только для чтения;
для сохранения или изменения содержимого
вызывающая сторона должна сделать копию,
при вызове до csvgetline поведение не определено.
int csvnfield(void);
возвращает количество полей в последней строке,
прочитанной csvgetIi ne. при вызове до csvget line
поведение не определено.
Представленная спецификация все еще оставляет некоторые вопро- < сы
открытыми. Например, какие значения должны возвращать csvf ield и csvnf
ield, если они вызваны после того, как csvgetline натолкнулась на EOF?
Разрешить подобные головоломки непросто даже для маленькой I программы,
а для больших систем — исключительно трудно, но очень важно хотя бы попробовать
с ними справиться. В противном случае вы рискуете обнаружить пробелы и
недочеты уже в ходе реализации проекта.
Остаток параграфа посвящен новой реализации csvgetl i ne, которая COOT- | ветствует нашей спецификации. Библиотека разбита на два файла — заголовочный csv. h и файл воплощения csv. с. В заголовочном файле содержатся объявления функций, то есть представлена общедоступная часть интерфейса. В csv. с содержится собственно рабочий код библиотеки — реализации функций. Пользователи включают csv. h в свой исходный код и компонуют свой скомпилированный код со скомпилированной версией csv. с; таким образом, исходный код библиотеки никогда не должен быть видим.
Внутренние переменные, в которых хранится текст, и внутренние функции
вроде split объявлены статическими (static), так что они видны только
внутри содержащего их файла. Это простейший способ сокрытия информации
в программе на С.
Переменные инициализируются также статически. Эти начальные значения используются
для проверки необходимости создания или наращивания массивов.
Эти объявления описывают простую структуру данных. Массив line содержит вводимую строку; массив sline создается путем копирования символов из line и вставки разделителя после каждого поля. Массив field указывает на значения в sline. На диаграмме показано состояние этих трех массивов после того, как была обработана строка ab, "cd", "e""f",, "g, h". Заштрихованные элементы в sline не являются частью какого-либо поля.
А вот как выглядит сама функция csvgetline:
Поступающая строка накапливается в строке line, которая при необходимости наращивается, вызывая realloc; при каждом увеличении размер удваивается, как в параграфе 2.6. Массив sline всегда увеличивается до размера line; csvgetline вызывает split для создания в отдельном массиве field указателей на поля — этот массив также при необходимости наращивается.
Мы привыкли начинать с очень маленьких массивов и увеличивать их по потребности, чтобы иметь гарантию, что код увеличения массива был выполнен. Если выделения памяти не происходит, мы вызываем reset для восстановления глобальных значений в их первоначальное состояние, чтобы дать шанс на успех последующему вызову csvgetline:
Функция endof line нужна для выявления и обработки ситуаций, когда вводимая строка заканчивается символами возврата каретки, перевода строки, ими обоими вместе или даже EOF:
Здесь необходима отдельная функция, поскольку стандартные функции ввода
не обрабатывают все многообразие нетривиальных форматов, встречающихся
в реальных условиях.
Наш прототип использовал st rtok для определения следующего поля поиском символа-разделителя, которым в принципе является запятая. Однако при таком подходе невозможно обрабатывать запятые, находящиеся внутри кавычек. В split необходимо внести глобальные изменения (хотя ее интерфейс и не изменится). Представьте себе такие строки ввода:
Каждая строка содержит по три пустых поля. Для того чтобы split была в
состоянии корректно интерпретировать такие строки и им подобные, ее реализацию
придется глобально усложнить, — это классический пример того, как особые
случаи и граничные условия оказываются доминирующими в программе.
В теле цикла массив указателей на поля при необходимости увеличивается,
после этого вызывается очередная функция, осуществляющая поиск и обработку
следующего поля. Если поле начинается с двойной кавычки, advquoted находит
поле и возвращает указатель на разделитель, которым поле заканчивается.
В противном случае для поиска следующей запятой мы используем библиотечную
функцию strcspn(p, s), которая ищет в строке р следующее вхождение любого
из символов строки s; возвращает эта функция количество пропущенных символов.
Двойные кавычки внутри поля представляются парой смежных двойных кавычек; функция advquoted сжимает такую комбинацию до одной кавычки, а также удаляет кавычки, обрамляющие поле. Дополнительный код добавлен в попытке справиться с правдоподобным вводом, не подходящим под спецификацию, — таким, например, как "abc"def. В подобных случаях мы добавляем в конец поля все, что заключено между второй кавычкой и следующим разделителем. Оказывается, Microsoft Excel использует схожий алгоритм.
Поскольку входная строка уже разделена, то реализация csvfield и csvnfield
становится тривиальной:
Наконец мы можем модифицировать тестирующую программу и проверить эту версию библиотеки: поскольку копия строки ввода сохраняется (чего в прототипе не было), появилась возможность распечатывать сначала исходную строку, а потом уже полученные поля:
На этом версия на С завершена. Она обрабатывает строки произвольной длины
и делает нечто осмысленное даже при некорректном вводе. В результате нам
пришлось написать в четыре раза больше кода, чем в прототипе, при этом
некоторые фрагменты получились довольно запутанными. Подобное увеличение
объема и сложности кода — совершенно типично для перехода от прототипа
к полноценному продукту.
Упражнение 4-1
При разделении полей возможно несколько уровней "ленивости" - -разделять сразу все поля, но только после получения запроса на конкретное поле, выделять только запрошенное поле и, наконец, разделять все поля до запрошенного. Рассмотрите потенциальные преимущества и недостатки перечисленных способов; реализуйте их и замерьте скорость работы каждого.
Упражнение 4-2
Добавьте новые возможности в библиотеку. Сделайте так, чтобы:
а) разделитель мог принадлежать к произвольному классу символов;
б) для разных полей существовали разные разделители;
в) разделитель был заменен на регулярное выражение (см. главу 9). На что
будет похож получившийся интерфейс?
Упражнение 4-3
В нашей реализации библиотеки мы применили статическую инициализацию, используемую в С в качестве основы для одноразового переключения: если на входе указатель есть NULL, то выполняется инициализация. Можно, однако, сделать и по-другому: заставить пользователя вызывать некотору/ю специальную функцию инициализации — в ней, кстати, могут содержаться некоторые рекомендованные начальные значения для массивов. Попробуйте написать версию, которая объединяла бы все достоинства обоих подходов. Какую роль в вашей версии будет играть reset?
Упражнение 4-4
Спроектируйте и реализуйте библиотеку для записи данных в формате CSV. Простейшая версия может просто брать массив строк и печатать их с кавычками и запятыми. Более интересный вариант — использовать форматные строки как printf. В главе 9 вы найдете некоторые полезные советы.
Реализация на C++
В этом разделе мы напишем версию библиотеки CSV на C++, в которой постараемся преодолеть некоторые ограничения, имеющиеся в С~версии. Нам придется внести некоторые изменения в спецификацию, главным из которых станет то, что функции будут теперь обрабатывать строки C++ вместо массивов символов С. Использование строк C++ автоматически решит некоторые проблемы, связанные с хранением данных, поскольку управлением памятью вместо нас займутся библиотечные функции. Так, в частности, функции работы с полями будут возвращать строки, которые затем могут изменяться вызывающей стороной, — проект получится более гибким, чем в предыдущей версии.
Класс Csv определяет внешние спецификации, изящно скрывая при этом переменные и функции реализации. Поскольку объект класса исчерпывающе описывает все состояния экземпляра, мы можем обрабатывать сразу несколько переменных Csv, при этом каждая из них будет абсолютно независима, так что одновременно могут обрабатываться сразу несколько входных потоков CSV.
Для конструктора определены параметры, принимаемые по умолчанию, — такой
объект Csv будет читать из стандартного входного потока и использовать
обычный символ-разделитель; эти параметры можно изменить, задав другие
значения в явном виде.
Для работы со строками класс использует не строки С, а стандартные С++-классы st ring и vector. Для типа st ring не существует невозможного состояния — "пустая" строка означает всего лишь строку нулевой длины, и нет никакого значения, эквивалентного по своему смыслу NULL, который бы мы использовали как сигнал достижения конца файла. Таким образом, Csv: :getline возвращает введенную строку через аргумент, передаваемый по ссылке, используя возвращаемое значение для сообщений о конце файла или ошибках.
Операция += здесь переопределяется, чтобы добавлять символ в строку.
Несколько меньше изменений потребуется вносить в endof line. Нам точно так же придется считывать ввод посимвольно, поскольку ни одна из стандартных функций ввода не может обработать все многообразие вариантов.
А вот как выглядит новая версия функции split:
Поскольку strcspn не работает со строками C++, нам надо изменить и split,
и advquoted. Новая версия advquoted для поиска следующего вхождения символа-разделителя
использует стандартную С++-функ-цию find_first_of. Вызов s. find_first_of
(fieldsep, j) ищет в строке s первое вхождение любого символа из fieldsep,
начиная с позиции ]. Если вхождение найдено не было, возвращается индекс,
лежащий за концом строки, так что нам надо будет вернуть его обратно в
должный диапазон. Внутренний цикл for в advquoted добавляет в поле fid
все символы, расположенные до ближайшего разделителя.
Функция find_first_of используется также и в новой функции advplain, которая
обрабатывает обычные, не заключенные в кавычки поля. Еще раз подчеркнем,
что необходимость в этом обусловлена тем, что функции языка С вроде strcspn
не могут быть применены к строкам C++, которые представляют собой совершенно
особый тип данных.
И снова, как и в предыдущей версии, Csv::getfield абсолютно тривиальна,
a Csv: :getnfield настолько коротка, что воплощена прямо в описании класса.
Тестовая программа представляет собой несколько упрощенный йа-риант предыдущей версии:
Использование библиотеки в C++ незначительно отличается от версии на С.
В зависимости от компилятора новая версия в сравнении с С-версией дает
замедление от 40 % до четырех раз на большом файле из 30 000 строк примерно
по 25 полей на строку. Как мы уже выясняли при оценке быстродействия программы
markov, подобный разброс зависит от степени проработанности используемых
библиотек. Последнее, что остается добавить: исходный код версии C++ получился
примерно на 20 % короче.
Упражнение 4-5
Введите в версию C++ оператор [ ], чтобы к полям можно было обращаться как к csv[i].
Упражнение 4-6
Напишите библиотеку CSV на Java, а затем сравните все три версии с точки зрения простоты и ясности, надежности и скорости.
Упражнение 4-7
Перепишите C++ версию кода CSV с использованием класса STL
iterator.
Упражнение 4-8
Версия на C++ предоставляет возможность нескольким независимым экземплярам Csv работать одновременно, никак не мешая друг другу, — в этом выразилось важное достоинство инкапсуляции всего состояния объекта, экземпляры которого можно порождать многократно. Измените версию на С так, чтобы добиться подобного эффекта; для этого замените глобальные структуры данных структурами, выделение памяти для которых и инициализация осуществляются явным образом с помощью отдельной функции csvnew.
Принципы интерфейса
В предыдущих параграфах мы прорабатывали детали некоего интерфейса. Сформулируем теперь в общих чертах, что же такое интерфейс. Интерфейс -- это детализированная, описанная граница взаимодействия между кодом, предоставляющим некоторые возможности, и кодом, который эти возможности использует. Интерфейс определяет, что именно предоставляет своему пользователю некоторый законченный блок кода, каким образом функции (а может быть, и какие-то элементы данных) из этого блока могут быть использованы в остальной части программы. Интерфейс CSV предоставляет пользователю три функции: чтение строки, получение поля и возврат количества полей. Кроме них, пользователь не может получить от нашего кода ничего.
Для того чтобы оказаться удобным, интерфейс должен отвечать некоторым базовым требованиям: быть простым, общим, стандартным, предсказуемым, надежным, а также нести в себе возможность без потерь адаптироваться к изменениям запросов пользователей и своей внутренней реализации. В основе хороших интерфейсов лежат несколько принципов. Принципы эти тесно взаимосвязаны, а иногда даже противоречивы, но они помогут нам описать, что же происходит при пересечении границы между двумя частями программы.
Прячьте детали реализации. Реализация, которая стоит за интерфейсом, должна быть скрыта от остальной части программы — с тем чтобы ее можно было изменять, не затронув при этом ничего снаружи. Для этого принципа существует несколько терминов: сокрытие информации (hiding), инкапсуляция, абстракция, модульность и т. п.; все они описывают в общем одни и те же идеи. Интерфейс должен скрывать те детали реализации, которые не имеют отношения непосредственно к клиенту (пользователю интерфейса). Скрытые детали можно изменять, никак не затрагивая этим клиента: таким образом, можно постепенно улучшать интерфейс, наращивать его возможности и даже целиком заменить всю реализацию.
Базовые библиотеки большинства языков программирования дают хорошо известные примеры реализации этого принципа, хотя и не всегда удачно разработанные. Одна из наиболее известных среди них — это стандартная библиотека ввода-вывода в С, в ней содержится несколько десятков функций для открытия, закрытия, чтения, записи и другой обработки файлов. Реализация файлового ввода-вывода скрыта в типе данных FILE*; на самом деле его свойства можно даже посмотреть (они нередко высказаны в <stdio. h>), но использовать не стоит.
Если заголовочный файл содержит только название структуры, а не полное ее описание, то такой тип называют иногда непрозрачным типом, поскольку свойства его неизвестны, а все операции осуществляются через указатель.
Избегайте глобальных переменных; всюду, где это возможно, лучше передавать ссылки на данные через аргументы функций.
Мы настоятельно рекомендуем не делать видимыми никаких данных ни в каком виде, — если пользователи смогут по своему желанию менять значения переменных, то чересчур сложно будет сохранять целостность и непротиворечивость данных. С помощью интерфейсов функций достаточно просто задавать жесткие правила доступа, однако этот прин- | цип часто нарушается. Предопределенные потоки ввода-вывода вроде | stdin и stdout практически всегда определяются как элементы глобального массива структур FILE:
Таким образом, реализация получается абсолютно прозрачной, при ; этом, несмотря на то что stdin, stdout и stderr выглядят как переменные, присваивать им никаких значений нельзя. Специфическое имя__iob основано на соглашении ANSI С, гласящем, что два подчеркивания используются в начале тех имен служебных переменных, которые должны | быть видны. Таким образом, выбранное нами имя, скорее всего, не будет I конфликтовать с именами внутри самой программы.
Классы в C++ и Java — еще более удачные механизмы для сокрытия информации; их можно считать центральными средствами правильного I использования этих языков. Классы-контейнеры из ЗТЬдля C++, которые мы использовали в главе 3, заходят еще дальше: за исключением некоторых данных о производительности, никакой информации о деталях | реализации не имеется, — следовательно, разработчики библиотеки могут использовать абсолютно любые механизмы.
Ограничьтесь небольшим набором независимых примитивов. Интерфейс должен предоставлять все необходимые возможности, но не более того; части интерфейса по возможности не должны перекрывать друг друга в плане функциональности. С одной стороны, лучше иметь большое количество функций в библиотеке —.тогда можно подобрать любую необходимую комбинацию. С другой стороны, чем больше интерфейс, тем труднее его написать и поддерживать в дальнейшем, а кроме того, неоправданно большие размеры могут привести к тому, что его будет трудно изучить и, следовательно, использовать оптимально. "Интерфейсы прикладных программ" (Application Program Interfaces, или API) зачастую настолько велики, что ни один смертный, похоже, не в состоянии освоить их целиком.
Для удобства использования некоторые интерфейсы предоставляют несколько способов для выполнения той или иной операции; с этой тенденцией надо бороться. Стандартная библиотека ввода-вывода С предоставляет как минимум четыре разные функции для вывода символа в выходной поток:
Если потоком является stdout, то существует еще несколько возможностей. В принципе, удобно, но не все эти возможности так уж необходимы.
Узкие, специализированные интерфейсы предпочтительнее, чем глобальные, расширенные. Делайте что-то конкретное, и делайте это хорошо. Не добавляйте что-либо в интерфейс только потому, что это несложно сделать; не исправляйте интерфейс из-за ошибок в реализации. Например, вместо того чтобы использовать memcpy как скоростной вариант и memmove как вариант надежный, удобнее было бы иметь одну функцию, которая всегда была бы безопасна, а также быстра — когда это возможно.
Не делайте ничего "за спиной" у пользователя. Библиотечная функция не должна создавать никаких таинственных файлов и переменных или без предупреждения менять глобальные данные. Весьма аккуратно и обдуманно надо относиться к изменению вообще любых данных в вызывающей программе. Наша функция strtok не отвечает некоторым из перечисленных критериев. Например, сюрпризом для пользователя явится вписывание пустых байтов в середину введенной строки. Использование пустого указателя для обозначения места окончания предыдущего захода является потенциальным источником ошибок, а кроме того, исключает возможность одновременного использования нескольких экземпляров функции. Более логичным было бы создание одной функции, которая делила бы на лексемы исходную строку. Кстати, по аналогичным причинам наша вторая версия на С не может быть использована для работы с двумя входными потоками (вернитесь к упражнению 4-8).
Использование одного интерфейса не должно повлечь за собой применение других интерфейсов только для удобства разработчика интерфейса или реализации. Наоборот, интерфейс должен быть по возможности самодостаточным; если же такой у вас не получается, вы должны абсолютно явно описать все необходимые внешние услуги. В противном случае окажется, что вы взвалили бремя ответственности за поддержку интерфейса на своего клиента (пользователя). В качестве характерного примера можно вспомнить муки управления огромными списками заголовочных файлов в программах на С и C++ — заголовочные файлы могут содержать тысячи строк и содержать ссылки на десятки других заголовочных файлов.
Всегда делайте одинаковое одинаково. Очень важно обеспечить последовательность и систематичность интерфейса. Схожие действия должны выполняться схожими способами. Основные функции str. . . в библиотеке С нетрудно использовать даже без описания, поскольку все они ведут себя практически одинаково: поток данных идет справа налево, так же, как и в операции присваивания, и все они возвращают результирующую строку. Однако в стандартной библиотеке ввода-вывода С предсказать порядок аргументов в функциях трудно. В одних из них аргумент FILE* расположен первым, в других— последним; различается также порядок задания размера и количества элементов. А вот правила интерфейса алгоритмов для контейнеров STL хорошо унифицированы, так что предсказать, как будет вести себя незнакомая функция, совсем просто.
Надо стремиться и к внешнему согласованию интерфейса, то есть к сходству с другими, уже известными интерфейсами. Например, функции mem. . . в библиотеках С проектировались позднее, чем функции str. . ., и следуют их стилю. А стандартные функции ввода-вывода, f read и fwrite было бы куда проще использовать, если бы они больше походили на свои прообразы — read и write. В Unix ключи командной строки предваряются символом "минус", однако один и тот же ключ может иметь совершенно различный смысл — даже в родственных программах.
Если командный интерпретатор операционной системы всегда под-1 ставляет в текст шаблоны поиска вроде * в *. ехе, поведение будет единообразным. Но если эту подстановку будут делать отдельные программы, то единообразия ожидать трудно. Web-браузеру для перехода на ссылку достаточно однократного щелчка мыши, а во многих других приложениях для вызова программы и для перехода на ссылку применяется двои-ной щелчок; в результате многие пользователи совершенно автоматически используют двойные щелчки и в web-браузерах.
В одних программных средах изложенных принципов придерживаться проще,
чем в других, однако стремиться к их претворению в жизнь надо всегда.
Так, например, в С довольно трудно скрыть все детали реализации, но хороший
программист не станет злоупотреблять открытостью деталей, поскольку интерфейс
не должен быть привязан к частностям — это противоречит принципу сокрытия
информации. Комментарии в заголовочных файлах, имена особого вида (вроде__iob)
и тому подобные
вещи помогут максимально приблизиться к достойному поведению вашего интерфейса
в тех случаях, когда вы не можете сделать этого строгими методами.
Очевидно, что и любой проект интерфейса может быть хорош только до какого-то предела. Даже самый прекрасный интерфейс, используемый сегодня, может стать причиной проблем завтра; однако чем лучше он спроектирован, тем дальше отодвинуто это самое завтра.
Управление ресурсами
Одна из наиболее серьезных проблем, требующих решения при проектировании интерфейса библиотеки (а также класса или пакета), — это управление ресурсами, которыми библиотека распоряжается самостоятельно или совместно с вызывающим ее окружением. Наиболее важным из таких ресурсов является память: кто должен ее выделять и высвобождать? Кроме того, среди других ресурсов есть открытые файлы, а также переменные, значения которых представляют общий интерес. Грубо говоря, проблемы с ресурсами можно разделить на инициализацию, поддержание заданного состояния, совместное использование и копирование, а также высвобождение.
В прототипе нашего пакета CSV для задания начальных значений указателей, счетчиков и прочих подобных вещей применялась статическая инициализация. Однако подобный подход довольно ограничен: мы не можем вернуть библиотеку в начальное состояние после того, как были вызваны какие-либо функции этой библиотеки. Альтернативный способ инициализации — создание отдельной специальной функции, которая бы устанавливала все внутренние переменные в корректные начальные значения. При таком подходе возврат в стартовое состояние возможен в любой момент, даже после вызова функций библиотеки, однако пользователь должен будет сам вызывать эту функцию явным образом. Для этой цели функция reset из второй версии библиотеки могла бы быть сделана видимой (то есть public).
В C++ и Java для инициализации данных внутри класса используются конструкторы. Должным образом определенные конструкторы дают нам гарантию, что все данные класса инициализированы и способа создать неинициализированный объект не существует. Набор конструкторов может поддерживать различные виды инициализации. Так, мы могли бы снабдить Csv конструктором, получающим имя файла, или конструктором, получающим входной поток.
А как насчет копирования информации, обрабатываемой библиотекой, — такой, как вводимые строки и поля? Наша С-программа csvgetline предоставляет прямой доступ к вводимым данным (строкам и полям), возвращая указатели на них. У такого свободного доступа существует ряд недостатков. Пользователь может перезаписать память, так что информация окажется некорректной. Например, выражение вроде
strcpy(csvfield(1), csvfield(2));
может в целом ряде случаев сработать некорректно, — скорее всего, перезаписав начало второго поля, если оно окажется длиннее первого. Пользователь библиотеки должен сделать копию всей информации, которую нужно будет сохранить после очередного вызова csvgetline. Так, после выполнения вот такого фрагмента кода, указатель вполне может оказаться неверным, если второй вызов csvgetline приведет к новому выделению памяти для буфера строк:
Версия на C++ безопаснее, поскольку строки в ней являются всего лишь копиями,
которые можно менять как заблагорассудится.
Java использует ссылки для обращения к объектам, то есть ко всему, кроме базовых типов вроде int. Это более эффективно, чем создание копий, однако пользователь может быть введен в заблуждение, считая, что ссылка является копией; ошибка подобного рода имела место в ранней Java-версии программы markov. Надо сказать, что данная проблема является вечным источником ошибок при работе со строками С. Не стоит забывать, что при необходимости создания копии методы клонирования позволяют вам сделать и это.
Обратной стороной инициализации или конструирования чего-либо, является его финализация (finalization), или деструкция, — то есть очистка и высвобождение ресурсов после того, как они больше не нужны. Особенно важно высвобождение памяти. Очевидно, что программе, которая не высвобождает неиспользуемую память, этой самой памяти в какой-то момент не хватит. Как ни странно, большая часть современных программ страдает этим недостатком. Схожая проблема возникает и в ситуации, когда приходит время закрывать открытые файлы: если данные были буферизованы, этот буфер нередко надо уничтожить (а память, занимаемую им, очистить). Для функций стандартной библиотеки С высвобождение происходит автоматически после нормального окончания работы программы, все остальные случаи должны обрабатываться программой. В С и C++ стандартная функция atexit предоставляет способ получить управление непосредственно перед тем, как программа будет завершена нормально; создателям интерфейсов не стоит пренебрегать такой возможностью для высвобождения ресурсов.
Высвобождайте ресурсы на том же уровне, где выделяли их. Хороший способ управления выделением и высвобождением ресурсов —возложить ответственность за освобождение ресурса на ту же библиотеку, пакет или интерфейс, которые выделяют этот ресурс. Можно выразить эту мысль и другими словами: состояние ресурса не должно меняться в пределах интерфейса. Все функции наших библиотек CSV считывали данные из уже открытых файлов, и по окончании работы они оставляли файлы открытыми. Закрытием файлов должны были заниматься те, кто их открывал, то есть пользователи библиотеки.
Конструкторы и деструкторы C++ помогают строго выполнять это правило. Когда экземпляр класса выходит из области видимости или явным образом уничтожается, вызывается деструктор. В этом деструкторе можно уничтожать буферы, освобождать память, возвращать значения в исходное состояние и делать вообще все, что необходимо. В Java подобного механизма нет. Можно определить для класса метод финали-зации, однако нельзя быть уверенными, что он будет выполнен вообще, не говоря уже о том, чтобы выполниться в какое-то конкретное время. Таким образом, нельзя дать гарантий, что действия по высвобождению ресурсов будут выполнены, хотя зачастую можно предполагать, что это все же произойдет.
В Java, однако, существует механизм, оказывающий огромную помощь в управлении ресурсами, — встроенная сборка мусора (garbage collection). При запуске программы выделяется память под новые объекты. Способа удалить их явным образом просто нет, однако некая система времени исполнения отслеживает, какие объекты все еще используются, а какие нет, и периодически удаляет неиспользуемые.
Существуют различные способы реализации сборки мусора. В некоторых схемах отслеживается счетчик ссылок (reference count) — некоторое число, показывающее, сколькими объектами используется интересующий нас объект. Объект высвобождается, как только счетчик ссылок становится равным нулю. Эту технологию можно реализовать явным образом в С и C++ для управления совместно используемыми объектами. Другой алгоритм периодически ищет связи между выделенной областью памяти и всеми объектами, на которые имеются ссылки. Объекты, обнаруживаемые при этом, кем-то используются, объекты же, на которые никто не ссылается, соответственно, не используются и могут быть уничтожены.
Наличие автоматической сборки мусора не означает, что при проектировании можно оставить вопросы управления ресурсами без внимания. Нам все равно надо определить, возвращает ли интерфейс ссылки на совместно используемые объекты или их копии, а это оказывает большое влияние на всю программу. И вообще, бесплатной сборки мусора не бывает, за нее приходится платить дополнительными расходами на поддержание информации и высвобождение неиспользуемой памяти; кроме того, невозможно предсказать моменты, когда эта сборка мусора заработает.
Все описанные проблемы становятся еще более запутанными, если библиотека должна использоваться в среде, где ее функции могут исполняться одновременно в нескольких нитях управления — как, например, в многонитевой программе на Java.
Чтобы избежать лишних проблем, необходимо писать реентерабельный (reentrant, повторно вызываемый) код, то есть код, который бы работал вне зависимости от количества одновременных его вызовов. В реентерабельном коде не должно быть глобальных переменных, статических локальных переменных, а также любых других переменных, которые могут быть изменены в то время, как их использует другая нить. Основой хорошего проекта многонитевой программы является такое разделение компонентов, при котором они не могут ничего использовать совместно иначе, чем через должным образом описанный интерфейс. Библиотеки, в которых по небрежности переменные доступны для совместного использования, способны разрушить многонитевую модель. (В многонитевой программе использование st rtok может привести к ужасным последствиям, поскольку существуют другие функции из библиотеки С, которые хранят значения во внутренней статической памяти.) Если переменная может быть использована несколькими процессами, то необходимо предусмотреть некий блокирующий механизм, который бы давал гарантию, что в любой момент времени с ними может работать только одна нить. Здесь очень полезны классы, поскольку они создают основу для обсуждения моделей совместного использования и блокировки. Синхронизированные методы в Java предоставляют нити управления способ заблокировать целый класс или его экземпляр от одновременного изменения другой нитью; синхронизированные блоки разрешают только одной нити за раз выполнять фрагмент кода.
Многонитевое управление добавляет немало новых сложностей во многие аспекты проектирования и программирования; тема эта чересчур обширна, чтобы обсуждать ее в деталях на страницах этой книги.
Abort, Retry, Fail?
В предыдущих главах мы использовали для обработки ошибок функции вроде eprintf и estrdup — просто выводили некие сообщения перед тем, как прервать выполнение программы. Например, функция eprintf ведет себя так же, как fprintf (stderr, . . .), но после вывода сообщения выходит из программы с некоторым статусом ошибки. Она использует заголовочный файл <stdarg. h> и библиотечную функцию vfprintf для вывода аргументов, представленных в прототипе многоточием (...). Использование библиотеки stdarg должно быть начато вызовом va_start и завершено вызовом va__end. Мы еще вернемся к этому интерфейсу в главе 9.
Если аргумент формата оканчивается двоеточием (:), то eprintf вызывает
стандартную функцию st re г го г, которая возвращает строку, содержащую
всю доступную дополнительную системную информацию об ошибке. Мы написали
еще функцию weprintf, сходную с eprintf, которая выводит предупреждение,
но не завершает программу. Интерфейс, схожий с printf, удобен для создания
строк, которые могут быть напечатаны или выданы в окне диалога.
Сходным образом работает est rdup: она пытается создать копию строки и, если памяти для этого не хватает, завершает программу с сообщением об ошибке (с помощью eprintf):
Функция emalloc предоставляет аналогичные возможности для вызова
malloc:
Эти функции описаны в заголовочном файле eprintf. h:
Он включается в любой файл, вызывающий одну из функций, которые сообщают
об ошибке. Каждое сообщение об ошибке содержит имя программы, определенное
вызывающим кодом, — оно устанавливается и извлекается простейшими функциями
set prog name и prog name, описанными в том же заголовочном файле и определенными
в исходном файле вместе с eprintf:
Типичный пример использования выглядит примерно так:
что приводит к появлению сообщений вроде
markov: can't open psalm.txt: No such file or directory
Мы считаем эти "оберточные" функции вполне подходящими для наших собственных программ, поскольку они унифицируют обработку ошибок; кроме того, само их присутствие вдохновляет на поиск ошибок. Ничего сложного или особо выдающегося в них нет, так что вы можете запросто придумать для себя какие-то более подходящие варианты.
Представим теперь, что вместо создания функций для собственного использования нам надо разработать библиотеку, с которой будут работать другие программисты. Что должна делать функция из этой библиотеки при возникновении ошибки? Те функции, что мы только что написали, выводят сообщение и умирают. Для многих программ, особенно для небольших самостоятельных утилит, такое поведение может'быть вполне приемлемым. Для других же программ простой выход не годится, поскольку при этом другие части программы лишаются возможности хотя бы попытаться вернуться в нормальное состояние; характерным примером являются текстовые редакторы, — в них стоит приложить максимум усилий для сохранения редактируемого документа. В некоторых ситуациях библиотечные функции не должны даже выдавать никакого сообщения, поскольку существуют системы, где такое сообщение будет мешать отображению полезной информации или же, наоборот, просто сгинет бесследно. Для подобных случаев полезно записывать сообщения в некий отдельный журнальный файл (log file), который можно просматривать независимо.
Обнаруживайте ошибки на низком уровне, обрабатывайте на высоком. Существует общий принцип: ошибки должны обнаруживаться на самом низком уровне, какой только возможен; обрабатывать же их надо на высоком уровне. В большинстве случаев определять способ обработки ошибки должен вызывающий код, а не вызываемый. Библиотечные функции могут помочь в этом, обеспечивая приемлемую реакцию при сбоях, — например, при получении несуществующего поля в качестве аргумента не прерывать работу всей программы, а возвращать NULL. Или, как в csvgetline, возвращать NULL вне зависимости от того, сколько раз эта функция была вызвана после достижения конца файла.
Не всегда очевидно, какие же значения должны возвращаться при ошибках; мы уже сталкивались с проблемой возвращаемого значения у функции csvgetline. Хотелось бы, конечно, возвращать как можно более содержательную информацию, но при этом в такой форме, чтобы остальная часть программы могла использовать ее без труда. В С, C++ и Java это значит, что информация должна возвращаться в качестве результата функции и, возможно, в значениях параметров-ссылок (указателей). Многие библиотечные функции умеют различать обычные значения и специальные значения ошибок. Функции ввода типа getcha r возвращают значение, конвертируемое в char для нормальных данных, и некоторое неконвертируемое в char значение, например EOF, для обозначения конца файла или ошибки.
Этот механизм, однако, не работает, если функция может возвращать любые значения из возможного диапазона. Например, математические функции вроде log могут возвращать любое число с плавающей точкой. В стандарте IEEE для чисел с плавающей точкой предусмотрено специальное значение NaN ("not a number" — не число), означающее ошибку, — это значение и возвращается функциями в случае ошибки.
Некоторые языки, такие как Perl и Tel, предоставляют несложный способ группировки двух и 0олее значений в кортеж (tuple). В таких языках значение функции и код ошибки можно без проблем передавать совместно. В C++ STL имеется тип данных pai r, который можно использовать таким же образом.
Хотелось бы, по возможности, уметь различать исключительные значения типа конца файла или кода ошибок, а не запихивать их все в какое-то одно значение. Если значения нельзя разделить сразу же, можно поступить таким образом: возвращать одно значение для всех видов исключительных ситуаций и создать дополнительную функцию, которая бы возвращала дополнительную информацию об ошибке.
Именно такой подход используется в Unix и стандартной библиотеке С: многие системные вызовы и библиотечные функции возвращают в случае ошибки -1 и при этом устанавливают глобальную переменную errno; функция strerror возвращает строку, соответствующую номеру ошибки. В нашей системе программа
напечатает
nаnОхЮОООООО 33 Domain error
Обратите внимание на то, что errno должна быть предварительно очищена (как в приведенной программе), тогда при возникновении ошибки она установится в некоторое ненулевое значение.
Используйте исключения только для исключительных ситуаций. В некоторых языках для отлова нестандартных ситуаций и восстановления после них имеется специальный механизм исключений, или исключительных ситуаций (exceptions); таким образом предоставляется альтернативный способ управления работой программы при возникновении каких-либо проблем. Исключения не следует использовать для обработки обычных возвращаемых значений. Так, при чтении файла рано или поздно будет достигнут его конец; это должно обрабатываться посредством возвращаемого значения, а не исключения.
Рассмотрим такой фрагмент, написанный на Java:
Этот цикл считывает символы, пока не будет достигнут конец файла — ожидаемое событие, которое функция read отмечает возвратом значения -1. Однако, если файл не может быть открыт, возникает (или, как принято говорить, возбуждается) исключение, а не установка переменной in в null, как это было бы сделано в С или C++. Наконец, если в блоке t ry происходит какая-то другая ошибка ввода, также возбуждается исключение, обрабатываемое в блоке lOException.
Не стоит злоупотреблять исключениями: они сильно видоизменяют управляющую логику, что ведет к появлению достаточно сложных логических конструкций — потенциальных слабых мест программы. Вряд ли при неудачной попытке открыть файл, например, стоит возбуждать исключение. Последние лучпф оставить для действительно непредвиденных случаев вроде отсутствия свободного места на диске или.ошибок арифметики с плавающей точкой.
В С пара функций — setjmp и longjmp — предоставляет возможность реализовать механизм исключений на гораздо более низком уровне, но это настолько сложно, что мы не будем описывать, как это сделать.
Как насчет восстановления ресурсов при возникновении ошибки? Должна ли библиотека предпринимать попытки такого восстановления, если что-то идет не так, как надр? Как правило, нет, однако очень неплохо предусмотреть какой-то механизм, позволяющий удостовериться, что информация сохранилась в максимально корректной форме. Естественно, неиспользуемое пространство памяти должно быть высвобождено. Если же к каким-то, переменным еще возможен доступ, они должны быть установлены в осмысленные значения. Распространенной причиной ошибок является использование указателя на уже освобожденную память. Чтобы не попасться на эту удочку, достаточно в коде обработки ошибки, который высвобождает что-то, установить указатель, адресующийся к этому чему-то, в ноль. Функция reset во второй версии библиотеки CSV как раз и являлась нашей попыткой преодолеть некоторые из описанных проблем. Обобщая же все вышесказанное, отметим: надо добиваться того, чтобы библиотека оставалась пригодна к использованию даже после возникновения ошибки.
Пользовательские интерфейсы
До сих пор мы говорили главным образом об интерфейсах между компонентами
программы или несколькими программами. Но есть же и еще один, очень важный,
вид интерфейса — между программой и ее пользователями-людьми.
Большинство примеров программ в этой книге основаны на работе с текстом,
так что их пользовательский интерфейс представляется более-менее очевидным.
В предыдущем разделе мы выяснили, что ошибки надо отслеживать и сообщать
о них; при необходимости должны предприниматься попытки восстановления.
Сообщение об ошибке должно включать в себя всю доступную информацию и
быть максимально информативным для каждого конкретного контекста; незачем
выводить
estrdup failed
когда можно сообщить
markov: estrdup("Derrida") неудача: мало места в памяти
Нам ничего не стоит включить дополнительную информацию (вспомните, как мы это делали в estrdup), а пользователю это может помочь идентифицировать проблему или хотя бы просто подобрать корректные входные данные.
Если пользователь допустил ошибку, программа должна показать ему пример правильного ввода, как это сделано в функциях типа
Имя программы, вырабатываемое функцией prog name, идентифицирует источник
сообщения. Это особенно важно в случае, если программа является частью
какого-то большого процесса. Если программа будет выводить сообщения вроде
syntax error или estrdup failed, то пользователь может просто не понять,
откуда пришло сообщение.
Текст сообщений об ошибке, подсказок и окон диалога должен обязательно четко описывать допустимые значения: не утверждайте, что параметр слишком велик, а приведите диапазон допустимых значений для этого параметра. Когда это возможно, текст должен сам по себе являться корректным вводом, например полной командной строкой с правильно заданным параметром. Это не только даст возможность пользователю понять, чего же от него ждут, но и позволит сохранить такой выводимый текст в файле (или "вырезать" его с помощью мыши) и потом использовать для запуска какого-то следующего процесса. Здесь, кстати, сразу становится виден один из недостатков окон диалога: их содержимое довольно трудно запомнить для дальнейшего использования.
Эффективный способ создать хороший пользовательский интерфейс для ввода — спроектировать специализированный язык для установки параметров, контролирования действий и т. п. Интерфейсы, основанные на языках, мы подробно обсудим в главе 9.
Защитное программирование, то есть такое программирование, при котором можно быть уверенным, что программе не страшен никакой некорректный ввод, не только защитит пользователя от самого себя и своих ошибок, но и предохранит всю систему в целом. Об этом речь пойдет в главе 6, посвященной тестированию программ.
Большинство людей пользуется сейчас графическими интерфейсами. Графические пользовательские интерфейсы — отдельная большая тема, поэтому мы упомянем лишь о нескольких связанных с ними моментах. Во-первых, графический интерфейс трудно сделать "правильным", поскольку его пригодность и удобство оцениваются пользователями субъективно. Во-вторых, с чисто практической точки зрения в системе с графическим пользовательским интерфейсом размер кода, обрабатывающего взаимодействие с пользователем, как правило, гораздо больше; чем код для любого самого сложного алгоритма.
Тем не менее в проектировании как внутренней реализации, так и наружного дизайна пользовательского интерфейса действуют одни и те же принципы. С точки зрения пользователя, хорошая проработка вопросов стиля — простоты, прозрачности, стандартности, предсказуемости, привычности и строгости — является синонимом хорошего интерфейса; отсутствие же перечисленных качеств наверняка приведет к зачислению интерфейса в разряд неудобных.
Стандартность и привычность интерфейса крайне желательны; это требование включает в себя последовательное использование терминов, модулей, форматов, шрифтов, цветов, размеров и всех остальных составляющих графическую среду элементов. Сколько различных английских слов используется для выхода из программы или закрытия окна? С десяток — от Abandon до control-Z; подобная непоследовательность может слегка запутать даже пользователя, для которого английский является родным языком, иностранца же она просто заводит в тупик.
Внутри кода, работающего с графикой, интерфейсам следует уделить особое внимание, поскольку эти системы, как правило, велики и сложны, а процесс ввода данных (и вообще получение реакции пользователя) весьма нетривиален. В разработке графических пользовательских интерфейсов большим преимуществом обладает объектно-ориентированная модель программирования, поскольку она предоставляет способ инкапсуляции состояний и поведения окон. При этом используется наследование для объединения одинаковых моментов в базовые классы и вынесения различий в классы-наследники.
Дополнительная литература
Несмотря на то что ряд технических деталей, описанных в книге "Мистический человекомесяц" Фредерика Брукса (Frederick P. Brooks, Jr. The Mythical Man Month. Addison-Wesley, 1975; Anniversary Edition, 1995)' уже устарел, она не перестала быть захватывающе интересной . и во многом столь же актуальной сегодня, как и двадцать лет назад.
Практически в каждой книге по программированию есть что-то интересное о проектировании интерфейсов. Практическим пособием, созданным на основе большого, потом и кровью добытого опыта, является книга "Разработка крупномасштабных программ на C++" Джона Лакоса (John Lakos. Large-Scale C++ Software Design. Addison-Wesley, 1996). В этой книге обсуждаются проблемы создания и управления действительно большими программами на C++. В создании программ на С поможет труд Дэвида Хэнсона "Си: интерфейс и реализация" (David Hanson. С Inter/aces and Implementations. Addison-Wesley, 1997).
Отличным рассказом о том, как писать программы в команде, является книга Стива Мак-Коннелла "Быстрая разработка" (Steve McCon-nell's. Rapid Development. Microsoft Press, 1996). В ней, кстати, особое внимание уделяется роли прототипа программы.
О проектировании графических пользовательских интерфейсов написано немало книг, авторы которых затрагивают различные аспекты этого процесса. Мы советуем:
Kevin Mullet, Darrell Sano. Designing Visual Inter/aces: Communication Oriented Techniques. Prentice Hall, 1995; Ben Shneiderman. Designing the User Inter/асе: Strategies/or Effective Human-Computer Interaction. 3rd ed. Addison-Wesley, 1997; Alan Cooper. About Face: The Essentials of User Interface Design. IDG, 1995; Harold Thimbleby. User Interface Design. Addison-Wesley, 1990. Брукс-мл. Ф. П. Как проектируются и создаются программные комплексы. М.: Наука, 1979; новое издание перевода: Мистический человекомесяц. СПб.: СИМБОЛ+, 1999.