Фундаментальные структуры данных, универсальность и сложность алгоритмов
Бывают в жизни такие вечера, что кажется, за день ничего не удалось сделать, и день прошел в тасовании каких-то вещей с одного места на другое. Зачастую работа программ напоминает такую деятельность. Большинство из них, подобноTraffic с его списковыми структурами, задающими линии метро, проводят большую часть времени, размещая объекты в репозитории и занимаясь поиском ранее сохраненных объектов.
Такой репозиторий – хранилище элементов (items), называют контейнером. Список является одним из примеров контейнера. Есть много других видов контейнеров, которые различаются памятью, требуемой для хранения элементов, и скоростью выполнения операций (вставка, получение, удаление элемента, поиск элемента, удовлетворяющего некоторому условию, операция, применяемая ко всем элементам контейнера).
В этой лекции мы изучим некоторые фундаментальные контейнерные структуры – массивы, разного вида списки, хэш-таблицы, стеки, очереди, – которые используются в самых разных областях приложения. В ходе этого рассмотрения нам предстоит осознать три важнейшие программистские концепции:
- роль типов в создании надежного ПО;
- универсальность: как объявлять классы контейнера, безопасные по типу хранимых элементов;
- алгоритмическую сложность, приемы оценивания производительности алгоритмов и структур данных.
По ходу рассмотрения мы встретимся с несколькими правилами хорошего стиля проектирования, такими как соглашение об именовании повторно используемых компонентов.
5.1. Статическая типизация и универсальность
Первая проблема, возникающая при работе с контейнером, – это проблема типизации.
Статическая типизация
Все сущности в наших программах объявляются с указанием типа. Это правило позволяет компилятору проверять, что любая операция, которую вы хотите применить к сущности x, например вызов метода x.f(a), использует метод, разрешенный для применения. Компилятор может:
- найти объявление сущности x и установить тип T, заданный при ее объявлении;
- найти класс, задающий тип Т;
- в этом классе проверить, что существует метод f, принимающий аргументы, число и тип которых соответствует аргументам в точке вызова.
Эта политика известна как статическая типизация: статическая, поскольку свойства типа специфицируются в тексте программы и могут быть проанализированы во время компиляции. Альтернативой является динамическая типизация, отказывающаяся от объявления типа и вынужденная ждать момента выполнения для обнаружения того факта, что метод не может быть применен к данной сущности. В предыдущих лекциях мы видели, что некоторые языки, как например, Smalltalk, предпочитают динамическую типизацию.
Выбор статической типизации, принятый в большинстве современных ОО-языков программирования, включая Java, C#, Eiffel, основан на двух основных аргументах:
- ясность: объявляя каждую сущность с точно определенным типом и каждый метод с точно определенной сигнатурой, мы задаем цели, стоящие перед нами, и облегчаем чтение и сопровождение программы;
- надежность: неверный вызов метода всегда является результатом программистской ошибки. Слишком большую цену приходится платить за позднее обнаружение ошибок в работающей программе, о чем уже шла речь ранее.
Статическая типизация для контейнерных классов
Как можно применить принципы статической типизации к контейнерам? Мы уже знакомы со списками, так как мы видели, что экземпляры LINE являются списками экземпляров класса STATION с методами, такими как
extend (s: STATION) — Команда —Добавить s в конец линии item: STATION — Запрос — Станция в текущей позиции курсора)
Предположим теперь, что мы хотим иметь класс LIST, который может описывать списки чего угодно: список станций метро, список целых чисел, список объектов некоторого заданного типа. Класс должен иметь вышеприведенные методы, но невозможно объявить тип s в методе extend или результат item без знания типа элементов списка: STATION, как выше, или любого другого типа, который вы выбрали для объектов конкретного списка.
Конечно, можно написать несколько классов: LIST_OF_STATION, LIST_OF_INTEGER и так далее. Не хотелось бы делать этого, так как тексты классов во многом были бы идентичными, за исключением некоторых объявлений. Такое дублирование или квази-дублирование противоречит принципам экономии и повторного использования.
Идея универсальности состоит в том, чтобы задавать один-единственный класс, но параметризованный, так, чтобы он мог поддерживать разные типы без перепрограммирования.
Универсальные классы (классы с родовыми параметрами)
Используя универсальность, объявим класс LIST следующим образом:
class LIST [G] feature extend (s:G) —Добавить s в конец списка. do … end item: G — Элемент в текущей позиции курсора. … Другие методы и инварианты… end
G – это просто имя, известное как формальный родовой параметр (таких параметров у класса может быть несколько). Родовой параметр задает тип, так что его можно применить для объявлений внутри класса, как в нашем примере для аргумента s в методе extend и при объявлении результата запроса item.
Какой же тип обозначает G? Сам класс на этот вопрос не отвечает. Используя класс LIST, можно объявить, например:
first_1000_primes: LIST [INTEGER] stations_visited_today: LIST [STATION]
Каждое такое объявление должно специфицировать тип, задав фактический родовой параметр – здесь INTEGER и STATION соответственно, указав тем самым, что обозначает G в данном конкретном случае.
Эта техника решает проблему статической типизации для общих контейнерных классов. Объявим переменные:
some_integer: INTEGER some_station: STATION
Следующие операторы будут правильными:
first_1000_primes.extend (some_integer) stations_visited_today.extend (some_station) some_integer:= first_1000_primes.item some_station:= stations_visited_today.item
Здесь все удовлетворяет правилам типа. Формальный аргумент extend в LIST имеет тип G; это значит INTEGER для first_1000_primes, объявленного как LIST [INTEGER], и STATION для stations_visited_today, поэтому вполне законно передать целое в качестве фактического аргумента в первом случае, и станцию метро – во-втором. То же справедливо и для результата item.
Но с другой стороны, следующие вызовы ошибочны:
first_1000_primes.extend (some_station) stations_visited_today.extend (some_integer)
На этапе компиляции возникнут ошибки:
До середины 80-х годов на шоссе 101 от Сан-Франциско до Лос-Анджелеса и Сан-Диего водителей ждало только одно прерывание – светофор в Санта-Барбаре, создающий вечную пробку. Губернатор Калифорнии в семидесятых годах Джерри Браун ответил на жалобы недовольных в чисто калифорнийском стиле, что на самом деле они должны быть благодарны за возможность сделать паузу и расслабиться. Именно так и следует реагировать на ошибки, полученные в период компиляции.
Почувствуй методологию
Когда компилятор отвергает ваш класс, не надо браниться. Выдохните, налейте чашечку зеленого чая, задумайтесь о смысле жизни. Тогда вы осознаете, сколько часов отладки вам пришлось бы потратить, если бы эта ошибка не была обнаружена сейчас, а встретилась позже в процессе выполнения программы, возможно уже у заказчика. Задумайтесь, как избежать подобных ошибок в будущем, и радуйтесь жизни.
В рассмотренном нами случае система типов современного языка программирования защищает нас от ошибок, в частности, ее механизм универсальности, обеспечивающий разумное сочетание гибкости и безопасности.
Дальнейшее рассмотрение требует ввода новых понятий.
Определения: родовой или универсальный класс, родовое порождение
Класс LIST является универсальным классом; тип LIST [INTEGER], полученный из LIST подстановкой родового параметра INTEGER, является родовым порождением LIST.
Все контейнерные классы, изучаемые в этой лекции, такие как ARRAY [G], LINKED_LIST [G], HASH_TABLE [G, H], являются универсальными, родовыми классами. Родовой параметр с именем G всегда задает тип элементов в контейнере. Конечно, для родового параметра можно выбирать любое имя, лишь бы оно не совпадало с фактическим именем одного из классов системы.
Универсальность – это название механизма, позволяющего классам иметь родовые параметры и, как результат, допускать типы, полученные родовым порождением.
Правильность против корректности
Целью механизма универсальности является, как отмечалось, проверка правильности некоторых видов программ (тех, что включают контейнерные структуры). Универсальность – это то, что делает "правильными" такие вызовы, как first_1000_primes.extend (some_integer). Правильность означает, что вызовы удовлетворяют правилам типа языка, а, следовательно, компилятор их допускает.
Это, однако, еще не означает, что такие операторы всегда будут работать корректно. Цель вызова first_1000_primes может быть void во время выполнения, extend может иметь предусловие, которому some_integer не удовлетворяет. Следует различать два разных понятия.
Определения: правильность, корректность
Правильная программа корректна, если она всегда выполняется в соответствии с желаемым поведением и никогда не станет причиной нарушения контракта или других сбоев в период выполнения, приводящих к отказам.
Определение корректности применимо только к правильным программам. Фактически, для статически типизированного языка (языка с точными правилами правильности, такого как Eiffel) нет смысла говорить о корректности, если программа не прошла проверку на "правильность".
Примером "некоторого вида неверного срабатывания", устраняемого проверкой на правильность, является попытка вызова объектом метода, который не применим для обработки.
Почему вводятся два понятия? Не было бы проще, если бы "правильность" влекла "корректность"? Зная, что программа "прошла" компилятор, можно было бы спокойно отдыхать, будучи уверенным, что во время исполнения программа будет работать нужным образом. Это Мечта программиста. Несмотря на то, что языки программирования с введением статических правил стали лучше и теперь способны обнаруживать ряд ошибок во время компиляции, все еще остаются ситуации, приводящие к ошибкам, которые могут быть обнаружены только во время выполнения. Для них предназначены механизмы периода выполнения, такие как "обработка исключительных ситуаций".
Давним предметом поиска, "философским камнем" в программистских исследованиях является стремление приблизить правильность к корректности. Граница регулярно изменяется, сближая эти два понятия. Вероятно, одним из наиболее важных достижений последнего времени является возможность исключения void-вызовов, благодаря правилам типа, использующим механизм присоединяемых типов, который кратко был описан в предыдущих лекциях.
То, что раньше было источником серьезных и непредсказуемых ошибок в период выполнения, теперь становится предметом стандартной проверки компилятора. Это важное свидетельство успехов на пути, ведущему к доказательству корректности программы.
До тех пор, пока доказательство не станет обыденным делом, правильность и корректность будут отличаться. Тем не менее, статическая типизация, особенно если она сочетается с контрактным проектированием, дает инструмент, позволяющий справиться с "жучками" до того, как они вас "укусят".
Классы против типов
Универсальность позволяет нам лучше разобраться в отношениях между классами и типами.
Тип – это описание множества значений периода выполнения: тип INTEGER задает свойства целых, тип STATION задает свойства объектов (периода выполнения), представляющих станции.
Класс является программным модулем, определяющим коллекцию компонентов (полей и методов) и их свойств, таких как инварианты класса, применимых к множеству объектов периода выполнения.
Связь этих двух понятий очень тесная: множество объектов периода выполнения, ассоциированное с классом, является типом, если только класс не является универсальным. Любой класс, не являющийся универсальным, такой как INTEGER или STATION, на самом деле представляет тип и может использоваться в качестве такового при объявлении сущностей, как это делалось в ранее приводимых примерах:
some_integer: INTEGER some_station: STATION
Классы используются двояко: как в роли базисных конструкций – модулей программы, являясь в этом случае статическим понятием, так и в роли механизма типизации объектов – динамическое понятие, центральное для ОО-программирования, которое лучше было бы называть КО-программированием (классо-ориентированным).
Связь между классами и типами остается такой же сильной и для универсальных классов. Новый поворот состоит в том, что универсальный класс, такой как LIST или ARRAY, более не задает тип – он задает шаблон типа, параметризованный тип. Для получения типа достаточно выполнить родовое порождение, задав фактические родовые параметры. Например:
- INTEGER и STATION являются классами, но они также являются и типами. Это справедливо для любого неуниверсального класса;
- LIST и ARRAY являются классами; LIST [STATION] и ARRAY [INTEGER] являются типами. Родовое порождение любого универсального класса является типом.
Дадим точное определение.
Определения: тип класса, универсально порожденный, базовый класс
Вложенные родовые порождения
Осталось уточнить, чем может быть фактический родовой параметр для универсального класса. Ответ напрашивается: типом. Вы могли видеть это в последних примерах: в LIST [STATION] фактический родовой параметр STATION является типом, так же как и INTEGER в ARRAY [INTEGER].
Возможно, вы ощутили некоторую странность в этих определениях.
- В только что приведенном определении типов (предложение Т2) говорится, что они могут быть получены из класса и фактических родовых параметров.
- Затем фактические параметры определяются как типы.
Не зацикливается ли это определение ("масло масляное")? Нет. Просто это пример рекурсивного определения, которое строит новые элементы из ранее определенных – в данном случае речь идет о построении множества типов. Рекурсивное определение должно иметь базовую, не рекурсивную часть определения. Процесс понятен:
- благодаря предложению Т1 определения мы знаем, например, что STATION – не универсальный класс, является типом;
- тогда мы можем использовать Т2, чтобы вывести, что ARRAY [STATION] также является типом.
Рекурсия – восхитительная техника, применяемая не только в подобных определениях, но и в программах и структурах данных. Мы посвятим ей отдельную лекцию, следующую за этой, но уже этого примера достаточно, чтобы показать, что ничего странного и несогласованного нет в рекурсивном определении "типа".
Определение открывает, фактически, интересные возможности. Тип, используемый в качестве фактического параметра в Т2, не обязательно определяется предложением Т1; он может определяться, в свою очередь, предложением Т2 – другими словами, параметр может быть универсально порожденным. Это позволяет задавать такие типы, как
LIST [LIST [INTEGER]] LIST [ARRAY [STATION]] ARRAY [ARRAY [ARRAY [INTEGER]]]
Такая вложенность допускается без ограничений. Это не только приятная теоретическая возможность, но и практичный механизм, который появится при определении списка списков, списка массивов и других многоуровневых контейнеров.