Введение в Java (по материалам Марко Пиккони)
Массивы
Массивы Java являются объектами, распределяемыми динамически, как это делалось в этой книге. Для определения массива используется объявление, такое как:
int[] arr; // Массив целых
Для создания объекта, представляющего массив, используется, как обычно, конструкция new:
arr = new int[size];
Здесь size - целочисленное выражение (не обязательно константа). В отличие от Eiffel, массивы не изменяют размеры. Доступ к элементам массива использует нотацию с квадратными скобками, как arr[i]. Следует помнить, что нижняя граница индексов фиксирована и равна 0, так что в вышеприведенном примере индексы меняются от 0 до size - 1. Элементу массива можно присвоить значение, например, так:
arr[i] = n;
Выражение arr.length (length - поле только для чтения) определяет число элементов массива. После вышеприведенного создания массива его значение будет size + 1 (так как size определяет значение верхней границы индекса, а нижняя равна 0). Типичная итерация по массиву, использующая цикл, детали которого будут приведены ниже, имеет вид:
for (int i=0; i < arr.length ; i++) {… Операции над arr[i] …}
Здесь i++ увеличивает целое i на 1. Заметьте, что условие продолжения цикла отражает тот факт, что максимальный допустимый индекс равен arr.length - 1.
Можно иметь многомерные массивы как массивы массивов, например:
int[][][] arr3; //Трехмерный массив arr3[i][j][k]
Обработка исключений
Исключение - это событие, происходящее во время выполнения программы, когда нормальное ее выполнение прерывается и не может быть продолжено без специальной обработки. Типичные причины события включают: вызовы с null-ссылками (void-вызов: x.f , где x имеет значение null), деление целого на нуль. В Java разработчик может явно включить исключение, когда в результате анализа проблемной ситуации становится ясно, что нормально продолжать работу данный алгоритм не может. Включение (выбрасывание) исключения делается так:
throw e1;
Здесь e1 - тип исключения, который должен быть потомком библиотечного класса Throwable. Точнее, тип исключения в большинстве случаев является потомком класса Exception, что справедливо и для типов исключений, определяемых программистом. Класс Exception является наследником класса Throwable. Другим наследником является класс Error, покрывающий системные ошибки периода выполнения. Программа Java способна обрабатывать возникающие исключения в следующем стиле:
try { … Нормальные операторы, во время выполнения которых потенциально может …возникнуть исключение … } catch (ET1 e) { … Обработка исключения типа ET1, детали исключения в объекте e … } catch (ET2 e) { … Обработка исключения типа ET1, детали исключения в объекте e … }… Другие возможные случаи … finally { … Финальная обработка, независимо от того, было исключение или нет … }
Если в try-блоке выбрасывается исключение одного из перечисленных типов, здесь ET1, ET2 …, то выполнение, прерванное в точке возникновения исключения, не будет продолжать работу try-блока, но продолжится в соответствующем catch-блоке. Блок finally, если он задан, выполняется во всех случаях: его типичное назначение - освобождение ресурсов, например, закрытие открытых файлов.
При любом появлении исключения автоматически создается объект исключения - экземпляр подходящего потомка Throwable. Программа обработки может получить доступ к этому объекту в соответствующем catch-блоке (во всех примерах е - имя этого объекта). У объекта, задающего исключение, есть свойства, такие как состояние стека вызовов, имя исключения в виде строки текста и другие.
Если встретилось исключение, чей тип не совпадает с типами, перечисленными в catch-блоках, или исключение встретилось вне охраняемого try-блока, то оно передается вверх по цепочке вызовов - вызывающему методу. Здесь опять-таки рекурсивно исключение может быть обработано, если предусмотрен catch-блок, или передано наверх. При завершении цепочки вызовов, если обработка исключения не выполнена, то программа завершается стандартным сообщением об ошибке - обрабатывается стандартным catch-обработчиком исключения.
В языке Java вводится интересное разграничение на "проверяемые" и "не проверяемые" - "checked" и "unchecked" - исключения. Положение в иерархии типов исключения с вершиной Throwable определяет, какие исключения являются проверяемыми, как показано на следующем рисунке:
Проверяемые исключения обеспечивают механизм, подобный контрактам: правило говорит, что если метод может выбрасывать проверяемое исключение, он должен объявить его, и тогда все клиенты, вызывающие метод, обязаны предусмотреть обработку исключения. Для указания того, что метод может выбрасывать исключение, используется ключевое слово trows (не путайте с оператором throw, который выбрасывает (включает) исключение):
public r(…)throws ET1, ET2{ … Код r, включающий операторы throw e1; // Для e1 типа ET1 throw e2; // Для e2 типа ET2 … }
Если r включает throw e3; для e3 проверяемого типа ET3, и e3 не появилось в предложении throws, метод признается ошибочным - если только его тело не содержит try-блок с ветвью в форме catch (ET3 e) , гарантирующий, что исключение будет обработано внутри метода, а не передано вызывающему методу.
Для вышеприведенного объявления любой вызов r из некоторого метода должен находиться в охраняемом блоке, сопровождаемом catch-блоками перечисленных типов, здесь ET1 и ET2.
Этот тщательно спроектированный механизм имеет привлекательные стороны, но и некоторые изъяны. Ограничение в том, что так можно заставить использовать throws-спецификации только для исключений, определенных программистом, в то время как большинство исключительных ситуаций связано с появлением системных исключений (void-вызовы и так далее). Когда правило заставляет программиста использовать try-блок, для ленивого программиста проще всего написать заглушку - ничего не делающий catch-блок, тем самым дискредитируя цель механизма. Вероятно, по этой причине в языке C# механизм исключений, практически идентичный механизму Java, не включает проверяемые исключения. Все же проверяемые исключения способствуют разумной дисциплине в обработке исключений, и их следует использовать для исключений, создаваемых программистом.
Наследование и универсальность
В исходном варианте Java присутствовало только единичное наследование и отсутствовала универсальность. Позже в язык была добавлена универсальность, но наследование так и осталось единичным, если не считать множественного наследования интерфейсов.
Наследование
Для указания того, что один класс наследует от другого, используется ключевое слово extends. Класс, наследующий от интерфейса, использует другое ключевое слово - implements. Оба варианта могут комбинироваться, но первым указывается extends:
public class F extends E implements I, J {…}
Класс без extends рассматривается как наследник Object - предка всех классов. Класс можно объявить как законченный - final, что запрещает наследование от него:
final class M …
У Java нет механизма переименования для разрешения конфликтов имен. Если два метода наследуются от класса и от интерфейса и их имена совпадают, но сигнатуры отличаются, то конфликта нет, поскольку в этом случае имеет место обычная для Java перегрузка. Если же у методов с совпадающими именами (они могут приходить и от двух разных интерфейсов) и сигнатуры совпадают, то методы конфликтуют, и нет простого способа разрешения конфликта.
Переопределение
Метод родителя можно переопределить у потомка. Такой метод не может быть статическим, и переопределение сохраняет сигнатуру. Если сигнатура не соблюдается, то это обычная перегрузка, а не переопределение. Здесь требуется особое внимание, так как оба механизма выполняются по умолчанию и не используют специальных ключевых слов. Просто объявляется новый член класса с тем же именем, что у родителя, и в зависимости от того, сохраняется сигнатура или нет, говорим о перегрузке, о переопределении или об ошибке, когда в классе появляются два метода с одним именем и одной сигнатурой.
Возвращаемый тип не является частью сигнатуры и не играет роли в правилах перегрузки. Для переопределяемого метода он обычно совпадает с типом оригинала, но он может быть и потомком типа оригинала. Такая ситуация известна как ковариантное переопределение (Eiffel предполагает ковариантное переопределение как для результата, так и для аргументов метода, что приводит к определённым проблемам системы типов).
Эквивалентом механизма Precursor для доступа к оригинальной версии переопределенного метода является конструкция super, уже встречающаяся при работе с конструкторами. Например:
public display(Message m) { // Переопределение родительского метода display super(m); // Выполнение метода display, заданного родителем … Другие операции, расширяющие работу родителя … }
Для полей (атрибутов) использование того же имени у потомка перекрывает оригинальную версию4 но не удаляет ее .
Переопределение члена может расширить статус видимости, но не ограничить его.
Полиморфизм, Динамическое связывание и Кастинг
Полиморфизм и динамическое связывание являются политикой умолчания, как в этой книге. Другими словами, если e1 типа E, f1 типа F, и F - потомок E, то можно использовать полиморфное присваивание:
e1 = f1;
После присваивания вызов в форме e1.r () будет использовать F версию r, если F переопределил метод r.
Полиморфные присваивания, такие как приведенные выше, известны как "кастинг, или приведение вверх". Обратное приведение, когда объект родительского типа приводится к специфическому типу потомка, известно как "кастинг вниз" и использует синтаксис, принятый в С++:
f1 = (F) e1;
Если e1 присоединен к объекту типа F, эта операция будет присоединять f1 к этому объекту; если же нет, то кастинг станет причиной исключения в соответствии с принципом кастинга. Можно предусмотреть перехват исключения в try-блоке и его обработку в catch-блоке, но лучше избежать этого через встроенную операцию instanceof:
if (e1 instanceof F ) {f1 = (F)e1;} else {… Обработка случая, когда e1 не обозначает объект F …}
Достигается эффект, подобный тесту объекта.
Универсальность
Концепции универсальности Java должны быть вам знакомы, поскольку о них шла речь при обсуждении неограниченной универсальности.
Родовые параметры заключаются в угловые скобки <…> . Объявим:
public class N <G, H> { … Тело класса … }
Класс N имеет два родовых параметра. Для построения родового порождения также используются угловые скобки:
N<T, U>
Подобно классам, интерфейсы могут быть универсальными. Ближайшим эквивалентом ограниченной универсальности является возможность объявлять формальный родовой параметр в следующей форме:
<? extends V>
Это означает, что соответствующий фактический родовой параметр должен быть потомком V.
Важным расширением механизма универсальности является возможность (отсутствующая в Eiffel) объявить универсальным отдельный метод, а не весь класс. Например, можно объявить:
public <G> List <G> repeated (int n, G val) {…}
У метода repeated два аргумента - целое n и значение произвольного типа G. В качестве результата метод возвращает список значений типа G (например, список из n одинаковых значений, заданных вторым аргументом). Рассмотрим объявление:
<String> repeated (27, "ABC")
Результатом будет список из 27 строк, с одинаковым значением "ABC".
Несколько ограничений влияют на универсальность.
- Нельзя использовать примитивные типы, такие как boolean и int, в качестве фактических родовых параметров. Вместо этого необходимо использовать объектные двойники Boolean и Integer.
- Классы, наследуемые от Exception, не могут быть универсальными.
- Для универсальных классов не допускается статический контекст.
Другие механизмы структурирования программ
Управляющие структуры Java в основном заимствованы от C и C++.
Условный оператор и оператор выбора
Условный оператор имеет форму:
if(boolean_expression){ … } else{ … }
Разрешается опускать скобки для then- или else-части, если они состоят из одного оператора (но лучше их оставлять для облегчения будущей модификации).
Отсутствует эквивалент elseif, так что нужно использовать вложенность. Для придания визуальной структуры используются отступы:
if (expression) {…} else if (expression) {…} else if (expression) {…} … else statement
Оператор switch задает структуру множественного выбора, хотя он и представляет многоцелевой goto, а не правильную структурированную конструкцию "один вход - один выход". Множественный выбор в Java, наследованный от С, имеет вид:
switch (expression) { case value: statement; break; case value: statement; break; … default: statement }
В этой конструкции expression должно быть целочисленного типа (short, byte, int или их объектные двойники) или символьного типа (char или Character). Каждое значение value должно быть константным выражением, вычислимым в период компиляции, и иметь совместимый тип с выражением. Если значение выражения не соответствует ни одной из этих констант, то выполняется ветвь по умолчанию - default, если она задана, в противном случае ничего не делается (в Eiffel в подобной ситуации возникнет ошибка периода выполнения).
Каждая ветвь должна заканчиваться оператором break, как показано. В противном случае управление будет "проваливаться" в следующую ветвь. Этот оператор, организующий выход из структуры, применим и для других управляющих структур - условного оператора и оператора цикла, как вскоре будет показано.
Другой подобной конструкцией является оператор continue, который может применяться в циклах. В отличие от break, он не приводит к выходу из цикла, а осуществляет переход на новую итерацию - проверку теста условия цикла.
При обсуждении управляющих структур говорилось, что лучше держаться подальше от таких goto-подобных конструкций.
Чтобы убедиться, что break или continue передает управление в нужное место, можно использовать запись их, сопровождаемую метками перехода, где в роли меток выступают идентификаторы и, конечно же, предполагается, что мы имеем дело с размеченной структурой программы:
label: … Управляющая структура (if, switch или loop) …
В то время как непомеченные операторы break и continue организуют выход из непосредственно охватывающей структуры, помеченная форма позволяет перепрыгивать на несколько уровней. Если применять эти операторы, то лучше использовать помеченную форму для уменьшения вероятности ошибок5Спорный совет. Непомеченная форма - это аналог goto вперед, допускаемый некоторыми авторами. Помеченная форма ближе к настоящему goto и может создавать большую неразбериху в понимании текста: оператор перехода на одной странице, метки - на других. Лучший совет - не использовать эти конструкции..
Циклы
Java предоставляет три вида циклов:
while (boolean_expression) statement do statement while (boolean_expression); for (init_statement ; boolean_expression ; advance_statement) body_statement
Во всех этих вариантах boolean_expression служит условием продолжения. Это отличается от соглашения для формы from … until … loop … end, принятой в этой книге, где until-выра-жение используется как условие выхода. Для преобразования условия из одной формы в другую достаточно применить операцию отрицания.
Разница между первыми двумя формами цикла состоит в точке проверки условия продолжения - в начале цикла или в конце. В первом варианте тело цикла может ни разу не выполняться, во втором гарантируется, что тело цикла будет выполнено, по крайней мере, один раз.
Цикл for - наиболее общий и наиболее часто используемый. Целью advance_statement является обеспечение продвижения к следующей операции (в Eiffel эта часть включается в тело цикла). Приведем пример цикла в Eiffel:
from i := 1 until i > n loop … i := i + 1 end
Его эквивалент в Java:
for (int i = 1; i <= n; i++) {…}
Язык также обеспечивает современную форму цикла, упрощающую итерирование по коллекциям (контейнерам):
for (variable: collection){ ... }
Соответствующий эквивалент Eiffel достигается механизмом итерирования контейнерных классов с использованием агентов.
Отсутствующие элементы
Несколько механизмов, заслуживающих доверия, не представлены непосредственно в Java. Давайте посмотрим, как достичь их эффекта.
Проектирование по контракту
Java не содержит прямой поддержки проектирования по контракту (отсутствуют предусловия, постусловия, инвариант класса и инварианты цикла). В версии Java 1.4 введен оператор утверждения assert, используемый в форме:
assert boolean_expression;
Этот оператор (подобный оператору check в Eiffel) вычисляет boolean_expression, он ничего не делает, если вычисленное значение - true, в противном случае выбрасывает исключение типа AssertionError - потомка Error, следовательно, непроверяемого. Этот оператор может быть использован для вставки утверждений в определенные точки программы для мониторинга ее поведения. Если вставить такой оператор в начало тела метода (где должно выполняться предусловие) или в конец (где должно иметь место постусловие), то появляется возможность моделирования этих механизмов. Конечно, это дает лишь небольшую часть полного механизма проектирования по контракту, в частности, применения к документированию, наследованию, понятия инварианта.
Обнаружив важность отсутствующего механизма, многие группы предложили расширение языка, добавляющее контракты. Обычно эти расширения носят экспериментальный характер и используют препроцессор (инструмент, обрабатывающий расширенную версию языка и транслирующий текст расширения в стандартный Java-текст). Таких предложений десятки, наиболее известным и широко используемым вариантом является JML - Java Modeling Language, который служил основой важной работы по верификации ПО (смотри ссылку в разделе литература).
Множественное наследование
Возможность для класса реализовать множественное наследование означает возможность комбинирования нескольких абстрактных типов. Напрямую этого сделать нельзя, но мы увидим, как можно ослабить это ограничение, в частности, используя внутренние классы.
Агенты
Java не имеет агентов или подобного механизма, такого как делегаты C#. Поскольку строго типизированная структура языка исключает использование указателей функций, как в C и C++, это является серьезным недостатком для тех приложений, где желательно рассматривать операции как объекты: программирование, управляемое событиями, некоторые численные вычисления, аналогичные вычислениям интегралов, итерирование и другие приложения, рассмотренные при обсуждении агентов.
Java предложила альтернативу таким потребностям. Мы уже видели, что появились циклы со встроенным механизмом итерирования. Для большинства других случаев рекомендуемым Java решением является использование внутренних классов (детализированное на примере GUI в следующем разделе).
Долгое время сообщество Java отрицало необходимость введения новых средств в язык. В 1997 году появилась так называемая "белая" статья, где с убежденностью отстаивалась эта точка зрения и в целом взгляд на проблемы проектирования языка программирования. Со всем пылом демонстрировалось, что делегаты - которые были предложены для Java, но нашли воплощение в C#, - являются избыточным механизмом. В доказательство были написаны несколько примеров, где параллельно решались задачи с использованием внутренних классов и делегатов. В конечном итоге демонстрация показала превосходство того самого механизма, который в статье пытались похоронить. Это не удивительно, если вспомнить обсуждение в этой книге, где показывалось, как отсутствие агентов усложняет программу.
Понадобилось почти полтора десятка лет, чтобы проектировщики сдались: объявлено, что Java 7 включает механизм, подобный агентам, известный как замыкания.