Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Любая компьютерная программа, в конечном итоге, сводится к машинному коду - набору двоичных инструкций, которые выполняются центральным процессором.
Чтобы задать эти инструкции, на заре компьютерной эры, в 40-х годах ХХ века, от программистов требовалось физически использовать различные переключатели и менять месторасположение различных перемычек. Это было чудовищно неудобно, так как компьютерную программу необходимо было вводить в компьютер вручную.
ENIAC, 27-тонный клубок из 18000 электронных ламп и диодов, занимавший площадь в 167 квадратных метров, считается первым в мире настоящим компьютером. Машина могла выполнить 5 000 операций в секунду.
В 50-х годах компьютеры начали использовать перфокарты. Перфокарты представляли собой картонные карточки, в которых необходимо было проделать в нужных местах отверстия, чтобы задать требуемые инструкции.
На заре компьютерной эры для программирования компьютеров использовался машинный код. Он представлял собой прямой набор инструкций процессора, а также аргументы для той или иной инструкции. Приведем пример программы для вывода надписи "Hello, world!" на машинном коде.
Для того, чтобы писать в машинных кодах, требуется досконально знать внутреннее устройство компьютера и подробности работы центрального процессора. Также, различные центральные процессоры различных фирм имели свой набор команд, что приводило к тому, что программа в машинных кодах писалась под конкретную модель центрального процессора.
Годом рождения языка ассемблера можно считать 1949 год. Язык ассемблера представляет собой систему обозначений, которая позволяет облегчить написание программ в машинном коде. Вместо кодов команд используются специальные обозначения (мнемоники), например "MOV" или "ADD". Также, можно было использовать различные системы счисления, а также давать меткам символические имена. Приведем пример программы, которая печатает на экране "Hello, world!" (диалект NASM Linux).
Необходимо отметить, что инструкции на языке ассемблера один в один транслируются в инструкции в машинных кодах (по крайней мере, так было изначально). Язык ассемблера предоставляет более удобную форму записи инструкций для процессора.
Особенностью программирования в машинных кодах и на языке ассемблера было то, что процесс разработки таких программ был очень медленный, отладка и поиск ошибок занимало огромное количество времени, а сами программы представляли собой научные расчеты для военной или аэрокосмической отрасли.
FORTRAN и неструктурированные языки программирования
С течением времени, компьютеры становились все более производительными и дешевыми. Компьютерами заинтересовались не только военные и ученые, а и различные коммерческие фирмы, возникла потребность в разработке все более сложных программ, которые занимались не только научными расчетами. Существующие языки (ассемблер и язык машинных команд) не позволяли писать большие и сложные программы, к тому же, стал вопрос об удешевлении стоимости написания программ для бизнеса.
Все это сформировало потребность в развитии Computer Science и появлении принципиально новых языков программирования, которые бы позволили писать сложные программы и тратить меньше времени и денег на их производство, внедрение и поддержку.
Адмирал Грейс Хоппер, разработчик одного из высокоуровневых языков программирования, так описывала необходимость в новых инструментах для разработки компьютерных программ:
Когда-то я была профессором математики. В то время я обнаружила, что есть студенты, которые не могут изучать математику. Затем мне поручили сделать так, чтобы предпринимателям было легко пользоваться нашими компьютерами. Оказалось, что вопрос не в том, смогут ли они изучать математику, а в том, захотят ли они. [ ... ] Многие из них говорили: "Выбросьте эти символы — я не знаю, что они означают, у меня нет времени их изучать’ А тем, кто заявляет, что люди обрабатывающие данные, должны использовать математическую нотацию, я посоветую для начала обучить математической записи вице-президента или полковника или адмирала. Уверяю вас, я уже пробовала.
В конце 1953 года, сотрудник IBM Джон Бэкус предложил более практичную альтернативу языку ассемблера для программирования компьютера IBM 704, которая называлась FORTRAN. Первый компилятор для языка FORTRAN был разработан в апреле 1957 года.
Язык FORTRAN стал невероятно популярным языком высокого уровня и позволил существенно повысить эффективность разработки, внедрения и поддержки компьютерных программ.
Концепция языков высокого уровня позволяет существенно упростить и ускорить процесс разработки программного обеспечения. Основная черта высокоуровневых языков — это абстракция, то есть введение смысловых конструкций, кратко описывающих такие структуры данных и операции над ними, описания которых на машинном коде (или другом низкоуровневом языке программирования) очень длинны и сложны для понимания.
Для того, чтобы объяснить один из главных недостатков языков программирования того времени, приведем пример программы на языке FORTRAN
Обратите внимание на строку
В этой строке проверяется переменная IB
. Что означает запись 777,777,702
? Если переменная меньше или равна 0, то выполняется строка с меткой 777 - программа останавливается с кодом ошибки 1. Если переменная IB
больше 0, то выполняется строка с меткой 702 - происходит проверка переменной IC
.
Программы того времени представляли собой большое полотно кода, который был плохо структурирован, а ветвления и циклы реализовывались с помощью переходов на определенную строку или метку.
По мере увеличения количества строк кода, такой способ написания программ приводил к такому явлению как "спагетти-код" (spaghetti code) - плохо структурированная и трудная для понимания программа.
Спагетти-код назван так, потому что ход выполнения программы похож на миску спагетти, то есть извилистый и запутанный. Иногда называется «кенгуру-код» (kangaroo code) из-за множества инструкций «jump».
Инструкции перехода jump повсеместно применяются в машинном языке и в языке ассемблера, но в языках высокого уровня их использование приводило к тому, что когда программа разрасталась до определенного размера, ее модификация, поддержка и отладка становилась очень дорогой и медленной.
Для примера, сравните две программы для печати числе от 1 до 10 и их квадратов, реализованных на неструктурированном языке BASIC и в код в стиле структурного программирования
Безусловно, неструктурированные языки были огромным шагом вперед по сравнению с языками низкого уровня, но они лишь на время отсрочили необходимость создания более совершенных языков и парадигм программирования, которые смогли бы открыть путь к безболезненной разработке более сложных программ.
Период середины с середины 60-х сегодня известен как "кризис программного обеспечения".
Термин software появился в 1958 году, а уже через 10 лет начали говорить о кризисе программного обеспечения — это словосочетание впервые прозвучало в 1968 году в докладе Питера Наура и Брайана Рэнделла «Программная инженерия», зачитанном на конференции Научного комитета НАТО.
На протяжении десятков лет появился целый ряд публикаций, посвященных Программному кризису 1.0. По оценке Пера Флаттена и его коллег, приведенной в докладе от 1989 года, в среднем на осуществление проекта разработки ПО уходило по 18 месяцев. Это консервативная оценка, если учесть, что в 1988 году авторы статьи в Business Week этот показатель назвали равным трем годам, а в 1982-м аналитики указывали, что на программные проекты уходит по пять лет.
В 1994 году в отчете об исследовании, проведенном специалистами IBM, утверждалось. что 68% всех программных проектов отстают от графика. Там же приводились сведения о том, что превышение бюджетов проектов разработки достигает 65%. Для обозначения программных систем, выпущенных, но неиспользуемых, даже придумали термин shelfware — «ПО на полку».
Эксперты компании Standish Group в докладе CHAOS Manifesto, выпущенном в 2011 году, сетуют на высокую долю провальных проектов, правда, применяемые ими методологию анализа и выводы подвергали сомнению. Программный кризис 1.0, похоже, уже прошел, и благодаря многочисленным инкрементальным усовершенствованиям процесса разработки ПО ситуация все-таки изменилась к лучшему. Практические перемены в конечном итоге привели к тому, что сегодня ПО, как правило, разрабатывается в пределах бюджета и отвечает техническим требованиям. К сожалению, теперь надвигается кризис 2.0, и, как показано на рисунке, его первопричина — неспособность создания ПО, эффективно использующего колоссальные объемы данных, которые появились за последние 50 лет, а также учитывающего возросшие технические характеристики устройств и требования их пользователей.
Чтобы продемонстрировать рост вычислительных мощностей на протяжении 60х годов, приведем один пример. Мейнфрейм IBM 1401, выпущенный в 1959 году, выполнял до 193 300 операций сложения в минуту. Модель IBM 360/91, выпущенная в 1968 году, выполняла до 16 600 000 операций сложения в секунду!
Приведем отрывок из выступления Эдсгера Дейкстры на вручении Премии Тьюринга в 1972 году.
Основная причина кризиса программного обеспечения заключается в том, что компьютеры стали на несколько порядков мощнее! Проще говоря: пока не было машин, программирование вообще не было проблемой; когда у нас было несколько слабых компьютеров, программирование стало небольшой проблемой, а теперь, когда у нас появились гигантские компьютеры, программирование стало столь же гигантской проблемой.
В соответствии с парадигмой, любая программа, которая строится без использования оператора goto, состоит из трёх базовых управляющих конструкций: последовательность, ветвление, цикл; кроме того, используются подпрограммы. При этом разработка программы ведётся пошагово, методом «сверху вниз».
Методология структурного программирования появилась как следствие возрастания сложности решаемых на компьютерах задач, и соответственно, усложнения программного обеспечения. В 1970-е годы объёмы и сложность программ достигли такого уровня, что традиционная (неструктурированная) разработка программ перестала удовлетворять потребностям практики. Программы становились слишком сложными, чтобы их можно было нормально сопровождать. Поэтому потребовалась систематизация процесса разработки и структуры программ.
Методология структурной разработки программного обеспечения была признана «самой сильной формализацией 70-х годов».
Статья Дейкстры в переводе - http://hosting.vspu.ac.ru/~chul/dijkstra/goto/goto.htm
Структурное программирование основано на принципе "вызова процедуры", что является еще одним названием "вызова функции". Процедуры также называют функциями, подпрограммами или методами. Процедура содержит последовательность исполняемых инструкций. Любая процедура может быть вызвана в любое время выполнения программа. Процедура может быть вызвана из другой процедуры или даже может вызывать саму себя.
Изначально, все процедуры были доступны в любой части программы, также как и глобальные данные. В небольших программах это не представляло никаких проблем, но чем сложнее и больше становится программа, тем чаще небольшие изменения в одной части программы могли существенно повлиять на другие части. Сложные и большие программы порождали огромное количество запутанных зависимостей и малейшее изменение в одной процедуре могло привести к каскаду ошибок во многих других процедурах, которые зависели от изменяемой процедуры.
ООП было спроектировано для того, чтобы облегчить проектирование, поддержку и повторное использование кода. Ключевыми концепциями ООП являются инкапсуляция и абстракция, которые используются для помощи в разработки больших и сложных программ. ООП - это инструмент для создания программ в миллионы строк кода таким способом, чтобы программы можно было понимать, управлять и поддерживать.
Корректное использование ООП позволяет делать код проще, безопаснее и проще для понимания и дальнейшей модификации. ООП также помогает повторно использовать функционал в другой части программы или даже в другой программе без серьезной модификации. Это помогает снизить повторное написание одного и того же кода и работу программиста в целом.
ООП также помогает делать код более стабильным и снижает количество багов, потому что когда часть кода корректно протестирована, вы можете быть уверен, что код не приведет к ошибкам при использовании его в другой части программы.
Дискуссии о том, какое программирование "лучше" не имеют смысла. Программирование, в том или ином виде, заключается в решении задачи, вы можете решить любую задачу с помощью любой парадигмы программирования, тем более, что многие языки программирования позволяют писать код в нескольких парадигмах.
Однако, не все парадигмы позволяют решать определенные задачи в равной степени эффективно. Таким образом, дискуссии насчет парадигмы программирования не имеют смысла, до тех пор, пока не определена задача, которую вы пытаетесь решить. Как только вы определили задачу и ее параметры, вы сможете понять, какая парадигма лучше подходит для ее решения.
Объектно-ориентированное программирование – это методология программирования, основанная на представлении программы в виде совокупности взаимодействующих объектов, каждый из которых является экземпляром определенного класса, а классы являются членами определенной иерархии наследования.
Объект – структура, которая объединяет данные и методы, которые эти данные обрабатывают. Фактически, объект является основным строительным блоком объектно-ориентированных программ.
Класс – шаблон для объектов. Каждый объект является экземпляром (instance) какого-либо класса («безклассовых» объектов не существует). В рамках класса задается общий шаблон, структура, на основании которой создаются объекты. Данные, относящиеся к классу, называются полями класса, а программный код для их обработки называется методами класса. Поля и методы иногда называют общим термином – члены класса.
Разница между классом и объектом такая же, как между абстрактным понятием и реальным объектом.
Объект состоит из следующих частей:
имя объекта;
состояние (переменные состояния). Данные, содержащиеся в объекте, представляют его состояние. В терминологии ООП эти данные называются атрибутами. Например, атрибутами работника могут быть: имя, фамилия, пол, дата рождения, номер телефона. В разных объектах атрибуты имеют разное значение. Фактически, в объектах определяются конкретные значения тех переменных (полей класса), которые были заявлены при описании класса;
методы (операции) – применяются для выполнения операций с данными, а также для совершения других действий. Методы определяют, как объект взаимодействует с окружающим миром.
Объекты могут отправлять друг другу сообщения. Сообщение (message) - это практически то же самое, что и вызов функции в обычном программировании. В ООП обычно употребляется выражение "послать сообщение" какому-либо объекту. Понятие "сообщение" в ООП можно объяснить с точки зрения ООП: мы не можем напрямую изменить состояние объекта и должны как бы послать сообщение объекту, что мы хотим как-то изменить его состояние. Очень важно понять, что объект сам меняет свое состояние, а мы можем только попросить его об этом с помощью отсылки сообщения.
В объектно-ориентированной программе весь код должен находиться внутри классов!
В классе описываются, какого типа данные относятся к классу, а также то, какие методы применяются к этим данным. Затем, в программе на основе того или иного класса создается экземпляр класса (объект), в котором указываются конкретные значения полей и выполняются необходимые действия над ними.
Давайте попробуем понять, что такое объект и класс с помощью языка программирования C.
Представим себе, что мы пишем программу для книжной лавки на языке C. В какой-то момент мы сталкиваемся с необходимостью хранить информацию о множестве книг: название книги, кто автор книги, год издания и стоимость книги. Как нам это запрограммировать?
Мы можем воспользоваться массивами и хранить данные о книгах в нескольких массивах.
Теперь мы можем обратиться к i-му номеру каждого массива для получения информации об i-ой книге.
Какими недостатками обладает данный способ работы с данными? Такой подход может приводить к многочисленным ошибкам (например, ошибки при работе с индексами массивов), такие данные тяжело модифицировать (удаление книги приводит к необходимости смещать влево часть элементов массивов), такой код неудобно читать, поддерживать и модифицировать.
Но самое главное - мы мыслим в контексте структуры компьютера, а не решаемой задачи. Для нас книга - это некий единый объект, который имеет некоторые параметры (атрибуты): название, количество страниц и так далее. Мы же представляем атрибуты этого объекта в виде отдельных записей в разных массивах, потому что на языке C мы вынуждены мыслить в терминах имеющихся структур данных (массивов, очередей, деревьев), а не в терминах отдельных объектов и их взаимоотношений. Это затрудняет понимание решаемой задачи (управление книжной лавкой и продажей книг), увеличивает количество ошибок в программе и на некотором этапе мы вообще перестаем понимать, что происходит в программе.
Для хотя бы какого-то решения этой проблемы и облегчения труда программиста, на некотором этапе в язык C было введено понятие структуры (ключевое слово struct
), которое вы должны были изучать в курсе "Алгоритмизация и программирование".
Структура в языке C - это тип данных, создаваемый программистом, предназначенный для объединения данных различных типов в единое целое. Таким образом, мы можем сгруппировать данные, которые относятся к одной книге, в одну структуру.
Сначала мы должны описать структуру (новый тип данных).
Таким образом, некоторое понятие реального мира (то есть, книга вообще, как понятие) в программе описан структурой Book
. Экземпляр этого понятия (какая-то конкретная книга) в программе будет представлен экземпляром структуры - переменной типа Book
. Мы объявляем экземпляр структуры, после чего мы сможет заполнить поля структуры и работать с ней дальше в программе.
Использование структур решает проблему лишь частично. Мы сгруппировали данные, но функции для работы с нашей структурой находятся вне структуры.
Объект реального мира обладает не только атрибутами (автор книги, название книги, количество страниц и так далее), но и определенным поведением (книгу можно продать, купить, переместить на склад и так далее). При разработке сложных программных систем необходимо группировать не только данные, но и функции, которые работают с этими данными.
Рассмотрим простой пример объектно-ориентированной программы. Представим, что мы программируем графический редактор, который может рисовать различные фигуры. Подумав над задачей, мы приходим к выводу, что в нашей программе должны быть объекты, которые представляют различные фигуры. Итак приступим.
Сначала мы должны создать классы и описать внутри этих классов данные, которые относятся к фигурам и поведение фигур в виде набора методов (функций).
Опишем класс для фигуры «Треугольник». Какие характеристики могут быть у класса «Треугольник»? От чего зависит набор характеристик? В принципе, любой объект является бесконечно сложным и для его описания понадобится бесконечно большое количество характеристик. Но для нашего простого графического редактора важными будут следующие параметры: координаты трех точек и цвет фигуры.
Здесь вы должны понять очень важную вещь: набор данных в классе зависит от той программы, которую мы собираемся написать. Например, если бы мы писали более мощный графический редактор, в списке полей класса мы добавили бы отдельно цвет заливки и цвет линий фигуры, сам цвет мог быть не только сплошным, но и в виде какого-то узора и так далее.
Для указания класса в Java используется ключевое слово class
, после чего идет название класса, далее мы ставим фигурные скобки и всё, что будет написано внутри фигурных скобок (переменные и функции) будет относиться к этому классу.
Какие переменные и типы данных будут моделировать координаты точек и цвет? Для моделирования цвета воспользуемся обычным целым числом (очень часто цвета моделируются обычным целочисленным значением).
Теперь попробуем подумать, а как нам смоделировать координаты трех точек? Если бы мы программировали на языке C, мы бы написали что-то вроде
Но давайте подумаем - координаты каждой точки логически связаны между собой и когда мы будем, например, перемещать треугольник или будем менять размер треугольника, будут одновременно меняться две переменные, которые моделируют одну точку. С точки зрения объектной модели, лучшим вариантом будет предусмотреть отдельную сущность, отдельный класс, который моделирует точку на двумерной плоскости. Таким образом, мы сгруппируем данные и код, который будет эти данные изменять (например, менять значение X и Y).
Теперь вернемся к классу Triangle
и укажем, что в качестве координат у нас будут выступать три объекта класса Point2D
.
Теперь давайте определимся с методами классов – то есть, к тому поведению, которое будут осуществлять объекты этих классов.
Какое поведение может быть у объекта класса «точка»? Совершенно точно это будет метод «изменить координаты». То есть, наша точка как отдельный объект может вести себя следующим образом – менять свои координаты. Давайте запрограммируем этот метод.
Теперь давайте подумаем, какое поведение будет у треугольника? Что с ним можно сделать? Ну, например, можно его перекрасить, можно его передвинуть в другое место на плоскости, можно его нарисовать на каком-то полотне и так далее. Давайте опишем некоторые из этих методов.
Итак, мы описали треугольник, но мы определили только понятие «треугольник», у нас нет их физически, код класса - это просто описание. Если вы создали java-проект, у вас, по умолчанию, будет присутствовать класс Main
и функция main()
. Пока что мы не будем объяснять, зачем нужен класс Main
, а обратим внимание на метод main()
. Этот метод является «точкой входа» в программу, с вызова этого метода начинается работа программы. Когда мы дойдем и выполним последнюю инструкцию внутри метода main()
, приложение завершится.
Создадим внутри метода main()
несколько объектов класса Triangle
. Создание объектов мы будем рассматривать подробно в следующих лекциях, на данный момент мы просто скажем, что для создания объекта используется ключевое словоnew
. После создания объекта, мы можем обращаться к объекту и вызывать методы у объектов (как мы вызывали функции в C).
Важно! Вы должны понять, что у разных объектов значения переменных будут разные!
То есть, где-то в оперативной памяти у нас создано два разных объекта. Внутри первого есть объект класса Point2D
, внутри этого объекта есть две переменные типа int
, у которых будут значения 140 и 180. Внутри же второго треугольника будет свой объект Point2D
, внутри которого будут совершенно другие две переменные типа int
, у которых будут значения 50 и 80. Это же будет касаться переменных color в двух разных объектах (рис.1.5).
Таким образом, когда мы описываем классы, мы заявляем, что объекты этих классов будут содержать определенные поля (набор переменных) и будут иметь набор методов (функций), которые можно вызвать у объекта. Каждый объект содержит свои переменные, но методы у них общие.
В Java разрешается в одном и том же классе определять два или более метода с одинаковым именем, если только объявления их параметров отличаются. В этом случае методы называются перегружаемыми, а сам процесс – перегрузкой метода (method overloading).
Если у методов одинаковые имена, как Java узнает, какой именно из них вызывается? Ответ прост: перегружаемые методы должны отличаться по типу и/или количеству входных параметров. Даже разного порядка аргументов достаточно для того, чтобы методы считались разными (хотя это не рекомендуется).
Перегрузка по возвращаемым значениям
Логично спросить, почему при перегрузке используются только имена классов и списки аргументов? Почему не идентифицировать методы по их возвращаемым значениям?
Идентифицировать их нельзя, потому что Java в этом случае не может определить, какая версия метода должна выполняться.
При вызове перегружаемого метода для определения нужного варианта в Java используется тип и\или количество аргументов метода. Следовательно, перегружаемые методы должны отличаться по типу и\или количеству их параметров. Возвращаемые типы перегружаемых методов могут отличаться, но самого возвращаемого метода недостаточно, чтобы отличить два разных варианта метода. Когда в исполняющей среде Java встречается вызов перегружаемого метода, в ней просто выполняется тот вариант, параметры которого соответствуют аргументам, указанным в вызове.
Перегрузка методов позволяет поддерживать принцип «один интерфейс, несколько методов».
В языках программирования без перегрузки методов, каждому методу должно быть присвоено однозначное имя. Но зачастую требуется реализовать, по существу, один и тот же метод для разных типов данных.
В таком случае, в языках программирования без перегрузки реализуют несколько методов, которые немного отличаются названиями.
Перегрузка методов ценна тем, что позволяет обращаться к похожим методам по общему имени. Следовательно, имя представляет общее действие, которое должно выполняться. Выбор подходящего варианта метода для конкретной ситуации входит в обязанности компилятора.
Ничто не запрещает вам реализовать несколько перегруженных методов, каждый из которых будет работать совершенно по-разному. Но на практике крайне рекомендуется, чтобы перегруженные методы реализовывали одну и ту же общую операцию.
Перегрузка конструкторов
Наряду с перегрузкой обычных методов можно также выполнять перегрузку конструкторов. Перегружаемые конструкторы – это норма и часто используемый прием.
Соответствующий перегружаемый конструктор вызывается в зависимости от параметров, указываемых при выполнении оператора new.
Если вы пишете для класса несколько конструкторов, иногда бывает удобно вызвать один конструктор из другого, чтобы избежать дублирования кода. Такая операция проводится с использованием ключевого слова this.
Когда мы объявляем новый класс, то мы фактически создаем новый тип данных, который можно использовать для объявления объектов данного типа. Создание объектов класса представляет собой двухэтапный процесс. Сначала следует объявить переменную типа класса.
Эта переменная является ссылочной, то есть она не содержит объект, а ссылается на него (примерно как указатель в C не содержит значение, а содержит адрес, то есть переменная ссылается на значение в памяти).
Затем нужно создать конкретный физический объект и получить на него ссылку. Эти операции выполняются с помощью оператора new
. Этот оператор динамически (то есть, во время выполнения программы) резервирует память для объекта, инициирует процесс создания объекта и возвращает ссылку на него (ссылка представляет собой адрес объекта в памяти). Далее нам необходимо сохранить ссылку в переменной.
В первой строке переменная mybox
объявляется как ссылка на объект типа Box
. В данный момент mybox пока еще не ссылается на конкретный объект, значение переменной равно null
. В следующей строке кода выделяется память для конкретного объекта, а переменной mybox
присваивается ссылка на этот объект.
После выполнения второй строки кода переменную mybox можно использовать так, как если бы она была объектом типа Box
. Но в действительности переменная mybox
просто содержит адрес памяти конкретного объекта типа Box
. Результат выполнения этих двух строк кода показан на рисунке 4.3.
Какие действия выполняет приведенный ниже фрагмент кода?
На первый взгляд, переменной b2
присваивается ссылка на копию объекта, на которую ссылается переменная b1
. Таким образом, может показаться, что переменные b1
и b2
ссылаются на совершенно разные объекты, но это не так. После выполнения данного фрагмента кода обе переменные, b1
и b2
, будут ссылаться на один и тот же объект. Таким образом, любые изменения, внесенные в объекте по ссылке в переменную b2
, окажут влияние на объект, на который ссылается переменная b1
, поскольку это один и тот же объект (рис. 4.4)
В общем случае, для передачи аргументов подпрограмме (в данном случае, методу) в языках программирования имеются два способа.
Первым способом является передача по значению. В этом случае значение аргумента копируется в параметр метода. Следовательно, изменения, вносимые в параметр метода, не оказывают никакого влияния на аргумент.
Вторым способом является передача по ссылке. В этом случае параметру передается ссылка на значение аргумента. Изменения, вносимые в параметр метода, будет оказывать влияние на аргумент, используемый при вызове.
Все аргументы в Java передаются по значению, но конкретный результат зависит от того, какой именно тип данных передается: примитивный или ссылочный.
Когда методу передается аргумент примитивного типа, его передача происходит по значению. В итоге создается копия аргумента, и все, что происходит с параметром, принимающим этот аргумент, не оказывает никакого влияния за пределами вызываемого метода.
При передаче объекта в качестве аргумента методу ситуация меняется коренным образом, поскольку объекты, по существу, передаются при вызове по ссылке. Не следует, однако , забывать, что при объявлении переменной типа класса создается лишь ссылка на объект этого класса. Таким образом, при передаче этой ссылки методу принимающий ее параметр будет ссылаться на тот же самый объект, на который ссылается и аргумент. По существу, это означает, что объекты действуют так, как будто они передаются методам по ссылке. Но изменения объекта в теле метода оказывают влияние на объект, указываемый в качестве аргумента.
Основной причиной чрезмерных затрат в программировании является "небезопасное" программирование.
Основные проблемы с безопасностью относятся к инициализации и завершению. Очень многие ошибки при программировании на языке C обусловлены неверной инициализацией переменных. Это особенно часто происходит при работе с библиотеками, когда пользователи не знают, как нужно инициализировать компонент библиотеки или забывают это сделать.
В языке C++ впервые появляется понятие конструктора - специального метода, который вызывается при создании нового объекта.
В Java разработчик класса может в обязательном порядке выполнить инициализацию каждого объекта при помощи специального метода, называемого конструктором. Если у класса имеется конструктор, Java автоматически вызывает его при создании объекта, перед тем как пользователи смогут обратиться к этому объекту. Таким образом, инициализация объекта гарантирована.
Конструктор – это специальный метод, который вызывается при создании нового объекта.
Синтаксис конструктора отличается от синтаксиса обычного метода. Его имя совпадает с именем класса, в котором он находится, и он не имеет возвращаемого типа.
Как было сказано выше, оператор new
динамически выделяет оперативную память для создания объекта. Общая форма использования оператора new
выглядит следующим образом
Имя класса, за которым следуют круглые скобки, обозначает конструктор данного класса. Конструкторы являются важной частью всех классов и обладают множеством важных свойств В большинстве классов, используемых в реальных программах, явно объявляются свои конструкторы в пределах определения класса
Инициализация всех переменных класса при каждом создании объекта – занятие довольно утомительное. В связи с этим, в Java разрешается выполнять собственную инициализацию при создании объектов. Такая инициализация осуществляется с помощью конструктора.
Еще раз обратите внимание, что имя конструктора совпадает с именем класса, в котором он находится, а синтаксис аналогичен синтаксису метода. Также конструктор не имеет возвращаемого типа - даже типа void
.
Большинство IDE для Java имеют механизм для генерации конструкторов. В IntelliJ IDEA нажмите комбинацию Alt+Insert
находясь в окне редактирования java-файла. Откроется контекстное меню Generate, где вы можете выбрать генерацию конструктора, после чего указать поля для инициализации.
Теперь нам должно быть понятно, почему при создании нового объекта, после имени класса требуется указывать круглые скобки. В действительности оператор new
вызывает конструктор класса.
Оператор new
вызывает конструктор Box()
. Но мы ранее не создавали этот конструктор, почему компилятор не выдал ошибку, когда мы запускали приложение?
Если в классе не определен конструктор, то в Java будет автоматически предоставлен конструктор по умолчанию.
Конструктор не получающий аргументов, называется конструктором по умолчанию (в документации Java он называется конструктор без аргументов).
Конструктор по умолчанию инициализирует все переменные экземпляра устанавливаемыми по умолчанию значениями, которые могут быть нулевыми, пустыми (null) и логическими (false) для числовых, ссылочных и логических типов соответственно. Зачастую конструктора по умолчанию оказывается достаточно для простых классов. Если же вы определите в классе хотя бы один конструктор, то конструктор по умолчанию создан не будет. Именно поэтому, следующий код выдаст ошибку.
Представим, что у нас есть два объекта одного класса и для этих двух объектов вызывается один и тот же метод:
Если существует один метод getArea()
, как метод узнает, для какого объекта он вызывается – для box_1
или дляbox_2
?
Оказывается, при вызове метода getArea()
(как и при вызове любого другого метода) передается скрытый первый аргумент – ссылка на используемый объект. Таким образом, вызовы методов на самом деле выглядят так:
Передача дополнительного аргумента относится к внутреннему синтаксису. При попытке явно воспользоваться ею компилятор выдаст сообщение об ошибке.
Предположим, во время выполнения метода нам необходимо получить ссылку на текущий объект. Так как эта ссылка передается компилятором скрытно, идентификатора для нее не существует. Но для решения этой задачи существует ключевое слово this
.
Ключевое слово this
может использоваться только внутри не-статического метода и предоставляет ссылку на объект, для которого был вызван метод.
Обращаться с ней можно точно так же, как и с любой другой ссылкой на объект. Для вызова метода класса из другого метода этого же класса, использовать ключевое слово this
не нужно.
Ключевое слово this
чаще всего используется в ситуации, когда локальная переменная скрывает поле класса. В Java не допускается объявление двух локальных переменных с одним и тем же именем в той же самой области действия. Однако, мы можем объявить локальные переменные, имена которых совпадают с именами полей класса.
Когда имя локальной переменной совпадает с именем переменной экземпляра, локальная переменная скрывает поле класса.
Можно решить эту ситуацию путем изменения имен локальных переменных, но это некорректно с точки зрения хорошего стиля написания кода. Грамотным решением является использование ключевого слова this
. Это позволит переменным иметь одинаковые названия, а к переменным экземпляра можно будет обратиться с помощью этого ключевого слова.
Иногда, во время выполнения метода необходимо получить ссылку на текущий объект, для которого был вызван метод. Так как ссылка на него передается скрытно, идентификатора для нее нет. Но для этого существует специальное ключевое слово – this
. Ключевое слово this
предоставляет ссылку на объект, для которого был вызван метод. Обращаться с ней можно как и с любой другой ссылкой на объект.
Инкапсуляция – один из основополагающих принципов ООП. Инкапсуляция – это одна из причин, почему так широко используется ООП.
Инкапсуляцию можно считать защитной оболочкой, которая предохраняет код и данные от произвольного доступа со стороны другого кода, находящегося снаружи оболочки. Доступ к коду и данным, находящимся внутри оболочки, строго контролируется тщательно определенным интерфейсом (набором общедоступных, публичных методов).
Инкапсуляция – это процесс отделения друг от друга элементов объекта, определяющих его устройство и поведения; инкапсуляция служит для того, чтобы изолировать контрактные обязательства от их реализации.
Программистов можно разделить на создателей классов (те, кто создает новые типы данных) и на потребителей классов (они используют уже кем-то ранее созданные классы для своих целей).
Цель потребителей классов – как можно быстрее написать программу, используя уже кем-то ранее созданные классы (как кубики в конструкторе).
Цель создателей классов – построить класс, открывающий только то, что необходимо программисту-клиенту, и скрывающий все остальное.
Почему так? Программист-клиент не сможет получить доступ к скрытым частям, а значит, создатель классов оставляет за собой возможность произвольно их изменять, не опасаясь, что это кому-то повредит. Скрытая часть обычно и самая «хрупкая» часть объекта, которую легко можно испортить неосторожный или несведущий программист-клиент, поэтому сокрытие сокращает количество ошибок в программах.
Создавая классы, вы устанавливаете отношения с программистом-клиентом. Если предоставить доступ ко всем членам класса кому угодно, программист-клиент сможет сделать с классом и нарушить логику его работы. Таким образом, первой причиной ограничения доступа является необходимость уберечь «хрупкие» детали от программиста-клиента – части внутренней кухни, не являющиеся составляющими интерфейса, при помощи которого пользователи решают свои задачи. На самом деле это полезно и пользователям – они сразу увидят, что для них важно, а на что можно не обращать внимания.
Вторая причина ограничения доступа – стремление позволить разработчику классов изменять внутренние механизмы класса, не беспокоясь о том, как это отразится на программисте-клиенте. Например, вы можете реализовать класс «на скорую руку», а затем переписать его, чтобы повысить скорость работы. При правильном разделении скрытой и открытой части, сделать это будет совсем несложно.
Никакая часть сложной системы не должна зависеть от внутреннего устройства какой-либо другой части.
Разумная инкапсуляция должна локализовать проектные решения, которые могут измениться. По мере эволюции системы, ее разработчики могут обнаружить, что какие-то операции выполняются недопустимо долго, а какие-то объекты занимают слишком много памяти. В таких ситуациях внутреннее представление объекта, как правило, изменяется, чтобы реализовать более эффективные алгоритмы или оптимизировать использование памяти, заменяя хранение данных их вычислением.
В любом классе присутствуют две части: интерфейс и реализация.
Интерфейс отражает внешнее поведение объектов этого класса. Внутренняя реализация описывает представления и механизмы достижения желаемого поведения объекта.
В интерфейсе собрано все, что касается взаимодействия данного объекта с другими объектами, а реализация скрывает от других объектов все детали, не имеющие отношения к процессу взаимодействия объектов.
Инкапсуляция позволяет локализовать части реализации системы, которые могут подвергнуться изменениям. По мере развития программы, разработчики могут принять решение изменить внутреннее устройство тех или иных объектов с целью улучшения производительности или экономии памяти. Но интерфейс будет нетронутым и позволит другим объектам таким же способом взаимодействовать с этим объектом. (Пример автомобиля – педали, руль, приборная панель и внутренняя начинка).
Инкапсуляция в Java реализована с помощью использования модификаторов доступа.
Язык Java предоставляет несколько уровней защиты, которые позволяет настраивать область видимости данных и методов. В Java имеется четыре категории видимости элементов класса:
private
– члены класса доступны только членам данного класса. Всё что объявлено private
, доступно только конструкторам и методам внутри класса и нигде больше. Они выполняют служебную или вспомогательную роль в пределах класса и их функциональность не предназначена для внешнего пользования. Закрытие (private
) полей обеспечивает инкапсуляцию;
по умолчанию (package-private) – члены класса доступны классам, которые находятся в этом же пакете;
protected
– члены класса доступны классам, находящимся в том же пакете, и подклассам – в других пакетах;
public
– члены класса доступны для всех классов в этом и других пакетах.
Модификатор класса указывается перед остальной частью описания типа отдельного члена класса. Это означает, что именно с него должен начинаться оператор объявления класса.
Когда член класса обозначается модификатором доступа public
, он становится доступным для любого другого кода в программе, включая и методы, определенные в других классах.
Когда член класса обозначается модификатором private
, он может быть доступен только другим членам этого класса. Следовательно, методы из других классов не имеют доступа к закрытому члену класса.
При отсутствии модификатора доступа, члены класса доступны другим членам класса, который находится в этом же пакете.
Модификатор доступа protected
связан с использованием механизма наследования и будет рассмотрен позже.
Модификатор доступа указывается перед остальной частью описания типа отдельного члена класса (то есть, именно с модификатора доступа начинается объявление члена класса).
Член класса (переменная, конструктор, методы), объявленный public
, доступен из любого метода вне класса.
Всё что объявлено private
, доступно только конструкторам и методам внутри класса и нигде больше. Они выполняют служебную или вспомогательную роль в пределах класса и их функциональность не предназначена для внешнего пользования. Закрытие (private
) полей обеспечивает инкапсуляцию.
В подавляющем большинстве случаев, поля класса объявляются как private
(это не касается статических переменных и констант, там ситуация может быть другая). Должны быть веские основания объявить поле класса общедоступным. Манипулирование данными должно осуществляться только с помощью методов.
Для того чтобы дать возможность получить доступ к переменной или дать возможность изменить ее значение, объявляют специальные методы, которые называются "геттерами" и "сеттерами".
Геттер возвращает значение приватного поля, тогда как сеттер меняет значение приватного поля (новое значение передается в качестве аргумента метода).
Хотя сигнатура и имена геттеров и сеттеров могут быть любыми, приучите себя соблюдать строгий шаблон для объявления геттеров и сеттеров.
Геттер должен иметь префикс get
, после которого идет название поля с большой буквы. Геттер, как правило, не имеет входных аргументов.
Сеттер должен иметь префикс set
, после которого идет название поля с большой буквы. Сеттер принимает на вход новое значение поля. Возвращаемый тип, как правило, void
.
Большинство IDE для Java имеют механизм для генерации геттеров и сеттеров. В IntelliJ IDEA нажмите комбинацию Alt+Insert
находясь в окне редактирования java-файла. Откроется контекстное меню Generate, где вы можете выбрать генерацию геттера и сеттера, после чего указать поля, для которых необходимо сгенерировать методы.
Представим, что нам необходимо создать класс «Корзина» (Cart
), который хранит в себе набор объектов класса «Товар» (Item
).
Какие методы «Корзина» должна предоставлять для внешнего использования? Это могут быть, например, методы «Добавить товар», «Убрать последний добавленный товар», «Подсчет суммы цен товаров в корзине», «Повышение цен в корзине на N процентов» и «Снижение цен в корзине на N процентов».
Как вы можете заметить, это публичные методы, а значит, их можно вызвать через оператор-точку имея ссылку ну объект.
Перечень этих публичных методов и составляет интерфейс класса – то есть, с помощью этих методов объект класса будет взаимодействовать с внешним миром.
Эти методы имеют вполне четко определенные входные аргументы и могут возвращать значения четко определенных типов, и никак иначе. По аналогии с этим, поворот колес автомобиля осуществляется четко определенным образом – поворотом руля, и бензин надо заливать в четко определенное отверстие крышки бензобака, а не как-то еще.
То – как будет реализовано хранение товаров в корзине – это внутренняя логика класса и она не должна быть доступна внешнему миру, она должна быть скрыта от внешнего вмешательства. Другие классы, которые будут использовать объекты класса Cart
не должны знать и не должны иметь доступ к тому – как там «внутри» реализовано хранение товаров, подсчет цен и изменение цены на определенный процент и так далее, они могут только лишь использовать предоставленные им публичные методы. Давайте реализуем «Корзину» с помощью структуры «стек», которая, в свою очередь, реализована обычным массивом.
Как мы видим, массив с товарами, указать на вершину стек объявлены как private
члены класса. Это значит, что мы не можем получить к ним доступ извне – они доступны только внутри данного класса.
Программиста, который будет использовать класс Cart
, не должна волновать ситуация с переполнением стека, с попыткой извлечь элемент из пустого стека, он не должен следить за указателем на вершину стека, он даже не должен знать что это стек.
Для него объект класса Cart
это некоторый объект, который предоставляет «услугу» в виде корзины товаров и с этой корзиной можно работать с помощью определенных публичных методов.
В дальнейшем мы можем переделать класс Cart
и поменять внутреннюю реализацию. Мы можем использовать структуру "очередь", мы можем использовать коллекции, мы можем иначе реализовать операции добавления и удаления элемента в стеке, но если мы сохраним интерфейс класса неизменным, то для внешнего мира эти изменения внутренней логики не будут важны и если мы поменяем внутреннюю логику одного небольшого участка программы, то вся остальная программа будет работать так же.
При сокрытии информации каждый класс (пакет, метод) характеризуется аспектами проектирования или конструирования, которое он скрывает от остальных классов. Секретом может быть источник вероятных изменений, формат файла, реализация типа данных или область, изоляция которой требуется для сведения к минимуму вреда от возможных ошибок. Класс должен скрывать эту информацию и защищать свое право на "личную жизнь". Небольшие изменения системы могут влиять на несколько методов класса, но не должны распространяться за его интерфейс.
Один из важнейших аспектов проектирования класса - принять решение о том, какие свойства сделать доступными вне класса, а какие оставить секретными.
Класс может включать 25 методов, предоставляя доступ только к пяти из них и используя остальные 20 внутренне. Класс может использовать несколько типов данных, не раскрывая сведений о них. Этот аспект проектирования классов называют "видимостью", так как он определяет, какие свойства класса "видимы" или "доступны" извне.
Интерфейс класса должен сообщать как можно меньше о внутренней работе класса. В этом смысле класс во многом похож на айсберг, большая часть которого скрыта под водой.
Как и любой другой аспект проектирования, разработка интерфейса класса - итеративный процесс. Если приемлемый интерфейс класса не удается создать с первого раза, сделайте еще несколько попыток, пока он не стабилизируется.
Приведем несколько правил использования инкапсуляции при проектировании классов:
Минимизация доступности - одно из нескольких правил, поддерживающих инкапсуляцию. Если вы не можете понять, каким делать конкретный метод: открытым, закрытым или защищенным - некоторые авторы советуют выбирать самый строгий уровень защиты, который работает.
Предоставление доступа в данным-членам нарушает инкапсуляцию. Например, класс Point
(точка), который предоставляет доступ к данным:
нарушает инкапсуляцию, потому что клиентский код может свободно делать с данными Point
что угодно, при этом сам класс может даже не узнать об их изменении. В то же время класс Point
, включающий члены:
поддерживает прекрасную инкапсуляцию. Вы не имеете понятия о том, реализованы ли данные как float
x
, y
и z
, хранит ли класс Point
эти элементы как double
, преобразуя их во float
, или же он хранит их на Луне и получает через спутник.
Истинная инкапсуляция не позволяла бы узнать детали реализации вообще. Они были бы скрыты и в прямом, и в переносном смыслах.
Класс следует спроектировать и реализовать так, чтобы он придерживался контракта, сформулированного посредством интерфейса. Выразив свои требования в интерфейсе, класс не должен делать предположений о том, как этот интерфейс будет или не будет использоваться.
То, что метод использует только открытые методы, не играет особой роли. Лучше спросите себя, согласуется ли предоставление доступа к данному методу с абстракцией, формируемой интерфейсом.
Объект – структура, которая объединяет данные и методы, которые эти данные обрабатывают. Это позволят разграничить область применения методов. Объект – это строительный блок объектно-ориентированных программ. Объектно-ориентированная программа является, по сути, набором объектов. Объект состоит из трех частей:
имя объекта;
состояние (данные объекта, переменные состояния). Состояние объекта характеризуется перечнем всех свойств данного объекта и текущими значениями каждого из этих свойств;
методы (операции).
Данные объектов. Данные, содержащиеся в объекте, представляют его состояние. В терминологии ООП, эти данные называются атрибутами. Например, атрибутами работника могут быть имя, фамилия, пол, дата рождения, номер телефона и так далее. В разных объектах атрибуты имеют разное значение.
Поведение объектов. Поведение объекта – то, что он может сделать (в структурном программировании это реализовывалось функциями, процедурами, подпрограммами).
Сообщения – механизм коммуникации между объектами. Например, когда объект А вызывает метод объекта B, объект A отправляет сообщение объекту B. Ответ объекта B определяется его возвращаемым значением. Только «открытые» методы могут вызываться другим объектом.
Каждый объект определяется общим шаблоном, который называется классом. В рамках класса задается общий шаблон, структура, на основе которой затем создаются объекты. Данные, относящиеся к классу, называются полями класса, а программный код для их обработки – методами класса. Поля и методы иногда называют общим термином – члены класса.
В классе описываются, какого типа данные относятся к классу, а также то, какие методы применяются к этим данным. Затем, в программе на основе того или иного класса создается экземпляр класса (объект), в котором указываются конкретные значения полей и выполняются необходимые действия над ними.
Согласно конвенции кода и правилам языка Java:
каждый класс должен содержаться в своем отдельном файле с расширением .java;
название файла должно совпадать с названием класса;
класс должен быть именем существительным;
имя класса должно его описывать;
имя класса начинается с большой буквы;
если имя состоит из нескольких слов, то каждое слово начинается с большой буквы.
Рассмотрим разницу между объектом и классом на примере. Определим класс Cat
и Dog
. Описание класса производится через указание полей (данных) и методов класса. Для класса Cat
в качестве полей укажем name
(кличку кота) и color
(окрас). Для класса Dog
задаем поля name
(кличка), color
(окрас) и breed
(порода).
Помимо полей, определим методы для этих классов. Методы – это то, что может делать объект класса (или что можно делать с объектом). Коты будут мяукать, и ловить мышей, а собаки лаять и вилять хвостом.
Таким образом, мы определили шаблоны, на основании которых впоследствии будут создаваться экземпляры классов или объекты. Разница между классом и объектом такая же, как между абстрактным понятием и реальным объектом. При создании объекта класса задаются конкретные значения для полей. Когда мы говорим о собаке или кошке вообще, как понятии, мы имеем в виду домашних животных, у которых есть имя, окрас и прочие характеристики. Это абстрактные понятия, которые соответствуют классу. А вот если речь идет о конкретном Шарике или Мурзике, то это уже объекты, экземпляры класса.
Рассмотрим синтаксис описания классов в Java. Описание класса начинается с ключевого слова class. После этого следует имя класса и в фигурных скобках тело класса. Тело класса состоит из описания членов класса – полей и методов.
Для объявления класса служит ключевое слово class. Упрощенная форма определения класса имеет вид:
Данные или переменные, определенные в классе, называются переменными экземпляра, поскольку каждый экземпляр класса (объект) содержит собственные копии этих переменных. Таким образом, данные одного объекта отделены и отличаются от данных другого объекта.
Код содержится в теле методов. В большинстве классов действия над переменными и доступ к ним осуществляют методы этого класса. Таким образом, методы определяют порядок использования данных класса.
Создадим класс Box
, который описывает контейнер, допустим, на каком-то складе.
Класс Box
определяет три переменные экземпляра: width
(ширина), height
(высота) и depth
(глубина). В настоящий момент класс Box
не содержит никаких методов.
Как мы уже говорили, класс определяет новый тип данных. В данном случае новый тип данных называется Box
. Это имя будет использоваться для объявления объектов типа Box
. Не следует забывать, что объявление class
создает только шаблон, но не конкретный объект. Таким образом, приведенный выше код не приводит к появлению каких-нибудь объектов типа Box
.
Чтобы действительно создать объект класса Box
, нужно воспользоваться оператором new
После выполнения этого оператора объект myBox
станет экземпляром класса Box
. Таким образом, он обретет "физическое" существование.
Также следует напомнить, что каждый объект содержит собственную копию переменной экземпляра, которая определена в классе. Каждый объект типа Box
будет содержать собственные копии переменных width
, height
и depth
(рис. 4.2).
Изменения в переменных экземпляра одного объекта не влияют на переменные экземпляра другого объекта. Таким образом, каждый объект класса Box
будет содержать собственные копии переменных width
, height
и depth
. Для доступа к этим переменным служит оператор-точка (.)
. Эта операция связывает имя объекта с именем переменной экземпляра. Например, чтобы присвоить переменной width
экземпляра myBox
значение 100, нужно выполнить следующий оператор:
Этот оператор предписывает компилятору, что копии переменной width
, хранящейся в объектe myBox
, требуется присвоить значение 100. В общем, операция-точка служит для доступа как к переменным экземпляра, так и к методам в пределах объекта.
Ниже приведет пример программы, в которой используется класс Box
Как пояснялось ранее, каждый объект содержит собственные копии переменных экземпляра. Это означает, что при наличии двух объектов класса Box
каждый из них будет содержать собственные копии переменных width
, height
и depth
. Следует, однако, иметь ввиду, что изменения в переменных экземпляра одного объекта не влияют на переменные экземпляра другого. Например, в следующей программе объявлены два объекта класса Box
:
Программа выводит следующий результат:
Как видите, данные из объекта myBox1
полностью изолированы от данных, содержащихся в объекте myBox2
.
Как упоминалось ранее, классы состоят из двух компонентов: переменных экземпляра и методов. Общая форма метода выглядит следующим образом:
где возвращаемый тип
означает конкретный тип данных, возвращаемый методом. Он может быть любым допустимым типом данных, в том числе и типом созданного класса. Если метод не возвращает значение, то его возвращаемым типом должен быть void
. В качестве имени методов может быть любой допустимый идентификатор, кроме тех, которые уже используются другими элементами кода в текущей области действия. А список параметров
обозначает последовательность пар "тип-идентификатор", разделенных запятыми. По существу, параметры - это переменные, которые принимают значения аргументов, передаваемых методу во время его вызова. Если у метода отсутствуют параметры, то список параметров оказывается пустым. Методы, возвращаемый тип которых отличается от void
, возвращают значение вызывающей части программы с помощью оператора return
.
Вернемся к нашему примеру с классом Box
. Было бы логично, если бы расчет объема коробки выполнялся в классе Box
, поскольку объем коробки зависит от ее размеров. Для этого добавим в класс Box
метод getVolume()
Внимательно рассмотрим две следующие строки кода
В первой строке вызывается метод volume()
для объекта myBox1
. Следовательно, метод volume()
вызывается по отношению к объекту myBox1
, для чего было указано имя объекта, а вслед за ним - операция-точка. Таким образом, в результате вызова метода myBox1.volume()
выводится объем коробки, определяемого объектом myBox1
, а в результате вызова метода myBox2.volume()
- объем коробки, определяемого объектом myBox2
.
При вызове метода myBox1.volume()
исполняющая система Jаvа передает управление коду, определенному в теле метода volume()
. По окончании выполнения всех операторов в теле метода управление возвращается вызывающей части программы и далее ее выполнение продолжается со строки кода, следующей за вызовом метода. В самом общем смысле метод - это способ реализации подпрограмм в Java.
В методе volume()
следует обратить внимание на еще одну очень важную особенность: ссылка на переменные экземпляра width
, height
и depth
делается непосредственно без указания перед ними имени объекта или операции-точки. Когда в методе используется переменная экземпляра, определенная в его же классе, это делается непосредственно, без указания явной ссылки на объект и применения операции-точки . Это становится понятным, если немного подумать. Метод всегда вызывается по отношению к какому-то объекту его класса. Как только этот вызов сделан, объект известен. Таким образом, в теле метода вторичное указание объекта совершенно излишне. Это означает, что переменные экземпляра width
, height
и depth
неявно ссылаются на копии этих переменных, хранящиеся в объекте, который вызывает метод volume()
.
Подведем краткие итоги. Когда доступ к переменной экземпляра выполняется из кода, не входящего в класс, где определена переменная экземпляра, следует непременно указать объект с помощью операции-точки. Но когда такой доступ осуществляется из кода, входящего в класс, где определена переменная экземпляра, ссылка на переменную может делаться непосредственно. Эти же правила относятся и к методам.
Следует обратить внимание, что метод getVolume()
возвращает значение 3000, и это значение рассчитанного объема сохраняется в переменной vol
. При обращении с возвращаемыми значениями следует принимать во внимание два важных обстоятельства:
тип данных, возвращаемых методом, должен быть совместим с возвращаемым типом, указанным в методе. Так, если какой-нибудь метод должен возвращать логический тип boolean, то возвратить из него целочисленное значение нельзя;
переменная, принимающая возвращаемое методом значение (например, vol
), также должна быть совместима с возвращаемым типом, указанным для метода.
Название метода | Описание |
| Конструктор с 1 параметром – максимальным количеством товаров в корзине. |
| Добавление товара в корзину. Возвращает успешность операции. |
| Удаление последнего добавленного товара в корзину. Возвращает удаленный товар. |
| Подсчет суммы цен всех товаров в корзине. |
| Поднять цены товаров в корзине на определенный процент (значение процента передается как аргумент метода). |
| Снизить цены товаров в корзине на определенный процент (значение процента передается как аргумент метода). |
При создании объектов с помощью оператора new возвращается ссылка на вновь созданный объект. Однако нас никто не обязывает эту ссылку присваивать в качестве значения ссылочной переменной. В таких случаях создается анонимный объект. Другими словами, объект есть, а переменной, которая бы содержала ссылку на этот объект, нет.
С практической точки зрения это может выглядеть бесполезным, однако, анонимные объекты требуются довольно часто - обычно в тех ситуациях, когда объект класса используется один раз. Рассмотрим пример:
В данном случае, нам нужен объект класса PrintManager
только для одного действия - для распечатки файла. То есть, мы создаем объект, вызываем метод, после чего объект нам больше не нужен.
В таких случаях удобно использовать анонимные объекты.
В первом случае используется обычный порядок работы с объектами - создаем объект класса PrintManager
, ссылка на объект записывается в ссылочную переменную manager.
Во втором случае мы создаем анонимный объект. Инструкция
выполняется следующим образом. Сначала создается новый объект класса PrintManager
, оператор new возвращает ссылку на созданный объект. После чего, мы вызываем метод printFile()
с аргументом file
.
В третьем случае, мы в качестве аргумента метода printFile()
передаем анонимный объект класса File
. В этом случае, ссылку на объект класса File
будет хранить параметр метода printFile()
внутри тела метода.
Механизм анонимных классов позволяет объявить класс и сразу создать его экземпляр. Это позволяет сделать код кратким и выразительным. Анонимные классы удобно использовать, если класс нужен единожды.
Основная особенность - анонимный класс не имеет имени. Анонимный класс может быть подклассом существующего класса или реализацией интерфейса.
Особенности анонимного класса:
нет явного конструктора;
к анонимному классу невозможно обратиться извне объявляющего его выражения;
анонимные классы не могут быть статическими;
анонимный класс всегда конечен (final
);
каждое объявление анонимного класса уникально.
В примере ниже объявлены два анонимный класса, которые являются подклассами PrintManager
. Оба анонимных класса являются разными уникальными классами.
Из-за особенностей реализации лямбда-выражений в Java, дальнейшая информация по поводу лямбда-выражений справедлива только для языка Java.
Лямбда-выражение - это анонимный метод, то есть метод без названия. Такой метод выполняется не самостоятельно, а служит для реализации функционального интерфейса. Таким образом, лямбда-выражение приводит к некоторой форме анонимного класса.
Функциональный интерфейс - интерфейс, который содержит один и только один абстрактный метод. Как правило, такой метод определяет предполагаемое назначение интерфейса. Следовательно, функциональный интерфейс представляет единственное действие. К функциональным интерфейсам можно отнести, например, интерфейс ActionListener.
Лямбда-выражения позволяют объявить метод и сразу же использовать его. Это полезно в случаях однократного вызова метода, так как сокращает время на объявление и написание метода без необходимости создавать отдельный класс. Лямбда-выражение в Java имеет следующий синтаксис:
Лямбда-выражение вносит новый элемент в синтаксис и оператор в язык Java. Этот новый оператор называется лямбда-оператором, или операцией "стрелка" ->
. Он разделяет лямбда-выражение на две части. В левой части указываются любые параметры, требующиеся в лямбда-выражении (если параметры не требуются, то они указываются пустым списком). А в правой части находится тело лямбда-выражения, где указываются действия, выполняемые лямбда-выражением. Операция ->
буквально означает "становиться" или "переходить".
В Java определены две разновидности тел лямбда-выражений. Одна из них состоит из единственного выражения (одиночное выражение), а другая - из блока кода (блочное выражение).
Для начала рассмотрим самое простое лямбда-выражение. Это будет выражение, которое не принимает никаких параметров, а возвращает константу. Также объявим метод, который аналогичен лямбда-выражению.
Приведем еще один пример уже более полезного выражения
Как вам понятно, лямбда-выражение возвращает псевдослучайное значение, умноженное на 100.
Два первых примера не принимают никаких параметров. Если же вам необходимо передать параметры, они указываются списком в левой части лямбда-оператора.
Тип параметров можно указывать явно, но зачастую в этом нет необходимости, потому что тип параметров выводится.
Предыдущие примеры содержали в теле выражения лишь одно выражение. Мы уже знаем, что такие лямбда-выражения называются одиночными.
Но ничего вам не мешает создавать блочные лямбда-выражения, которые в теле содержат блок выражений. В таком блоке можно создавать циклы, ветвления - все как в обычном блоке выражений.
При создании блочного выражения необходимо явно указать оператор return
.
Как было упомянуто ранее, в Java 8 было введено понятие функционального интерфейса - интерфейса с одним абстрактным методом. Лямбда-выражение не существует самостоятельно, а реализует абстрактный метод, определенный в функциональном интерфейсе.
Таким образом, лямбда может быть указана в том контексте, в котором определен ее целевой тип. Один из таких контекстов создается в том случае, когда лямбда присваивается ссылке на функциональный интерфейс. К числу других контекстов целевого типа относится инициализация переменных, оператор return и аргументы методов.
Механизм перечислений был добавлен в JDK версии 5 и позволяет удобно хранить и работать с так называемыми "категориальными данными".
Категориальные данные - это данные с ограниченным числом унекальных значений или категорий. Примеры категориальных данных:
месяц год (12 значений: январь, февраль и так далее);
вероисповедание (православие, католицизм, ислам и так далее);
день недели (понедельник, вторник, среда и так далее).
Рассмотрим пример. Для информационной системы "Электронный институт" необходимо создать класс сотрудника кафедры DepartmentMember
, в котором, среди прочих атрибутов, есть атрибут position
(должность). Предположим, что сотрудники кафедры могут занимать одну из следующих должностей:
Engineer;
Assistant;
Lecturer;
Senior Lecturer;
Professor.
Каким образом мы можем представить эти данные в компьютерной программе? Самый очевидный вариант - использовать строки.
Данное решение имеет несколько очень серьезных недостатков. Вы никак не можете обеспечить правильность указания должностей и контролировать создание нового объекта
Например, должность старшего преподавателя может быть записана как "ст. преп., ст. преподаватель, ст. пр." и так далее, не говоря про возможные орфографические ошибки. С точки зрения формальной логики, это все разные категории должностей и вы не сможете группировать или фильтровать сотрудников кафедры по полю "должность".
Второй вариант - создать несколько статических целочисленных констант, которые можно использовать вместо строк.
Теперь мы сможем использовать именованные константы вместо строго фиксированных строк.
Использование именованных констант позволяет отчасти избавиться от недостатков предыдущего подхода, но и этот вариант не является приемлемым. Ничто не мешает клиентскому коду при создании объекта указать целое число вместо именованной константы, осуществлять проверку ввода - достаточно трудоемкая задача, если в классе предусмотрено более одного категориального значения, будет тяжело определять, какие конкретно именованные константы необходимо использовать. К тому же, при использовании конструктора или сеттера поля position
, нет никакого указания на необходимость использования именованных констант, программист сам должен догадаться о наличии нужных констант или читать сопроводительную документацию.
Подход с использованием именованных констант напоминает улучшенную версию использования механизма специальных кодов и его не следует использовать при разработке коммерческих программ.
Корректным вариантом решения данной проблемы является использование специального механизма перечислений (enumeration). По сути, перечисление - это тип (то есть класс), ссылочная переменная которого может принимать одно из нескольких заранее определенных значений. Реализуем класс DepartmentMember
с помощью механизма перечислений
Преимущества такого подхода очевидны: поле position может принять только одно из заранее определенных значений, которые указаны при создании перечислений. При вызове конструктора или сеттера поля position, пользователю класса сразу будет понятно, что необходимо передать в качестве аргумента метода.
Обратите внимание на синтаксис перечисления. Перечисление является классом, но очень своеобразным. Вместо ключевого слова class
используется ключевое слово enum
(от слова enumeration - перечисление). В самом простом варианте, в перечислении просто указывается список констант, через запятую.
Как уже было сказано, перечисление - это класс, а это значит, что он может иметь методы, и мы можем инкапсулировать перечисление и операции работы с ним в одной оболочке.
В данном примере, экземпляр перечисления хранит строку с цветом.
Абстракция - упрощенное описание или изложение системы, при котором одни свойства и детали выделяются, а другие опускаются. Хорошей является абстракция, подчеркивающая детали, существенные для данной предметной области, и опускающая несущественные детали. Также абстракция позволяет отличать один объект от другого.
Принцип минимальных обязательств - интерфейс объекта должен описать только существенные аспекты его поведения;
Принцип наименьшего удивления - абстракция должна описывать только поведение объекта, ни больше, ни меньше.
Виды абстракций:
абстракция сущности - объект представляет собой полезную модель некоторой сущности в предметной области ("Студент", "Преподаватель", "Аудитория");
абстракция поведения - объект состоит из обобщенного множества операций ("Менеджер соединения с базой данных");
абстракция виртуальной машины - объект группирует операции, которые вместе используются более высоким уровнем управления;
произвольная абстракция - объект включает в себя набор операций, не имеющих друг с другом ничего общего.
Описывая поведение какого-либо объекта, например, автомобиля, мы строим его модель. Модель не может описать объект полностью, реальные объекты слишком сложны. Приходится отбирать только те характеристики объекта, которые важны для решения поставленной перед нами задачи.
Для описания грузоперевозок важной характеристикой будет грузоподъемность автомобиля, а для описания автомобильных гонок она не существенна. Но для моделирования гонок обязательно надо описать метод набора скорости данным автомобилем, а для грузоперевозок это не столь важно.
Для характеристики спортсмена обязательно надо указать его вес, рост, скорость реакции, спортивные достижения, а для ученого все эти качества несущественны, зато важно его квалификация, ученая степень, количество опубликованных научных работ и так далее.
Мы должны абстрагироваться от некоторых конкретных деталей объекта. Очень важно выбрать правильную степень абстракции. Слишком высокая степень даст только приблизительное описание объекта, не позволит правильно моделировать его поведение. Слишком низкая степень абстракции сделает модель очень сложной, перегруженной деталями, и поэтому непригодной.
При разработке программного обеспечения нам необходимо выбрать уровень абстракции, необходимый для правильного описания реального информационного процесса. Затем следует выделить объекты, принимающие участие в этом процессе, и установить связи между этими объектами.
Как это сделать? Можно воспользоваться следующей методикой: опишите процесс словами и проанализируйте получившиеся фразы. "Завод выпускает автомобили" - здесь два объекта: завод и автомобиль. Производственно-технические характеристики завода составят набор полей объекта "Завод", а процесс выпуска автомобиля будет описан в виде набора методов объекта "Завод".
Пример из другой области - "Преподаватель читает учебный курс". Полями объекта "Преподаватель" будут его фамилия, имя и отчество, научно-педагогический стаж, квалификация, ученая степень, выпущенные им учебники и методические пособия. Методами "Преподавателя" будут такие действия как "проводить лекцию", "повышать квалификацию", "проводить консультацию", "принимать зачет" и так далее.
Полями объекта "Учебный курс" будут его название, программа, количество часов, перечень учебных пособий. Будет ли объект "Учебный курс" обладать какими-то методами или в этом объекта будут только поля? Какие действия выполняет "Учебный курс"? По-видимому, единственным действием объекта "Учебный курс" будет предоставление своих полей другим объектам, значит, нужны методы доступа к полям объекта.
Очень упрощенно можно сказать, что если в словесном описании процесса вам потребовалось сформулировать какое-то понятие, то оно будет кандидатом на оформление его в виде класса. Существительные, описывающие это понятие, будут полями класса, а глаголы - методами будущего класса.
Кроме основных принципов ООП, некоторые ученые и авторы выделяют дополнительные принципы ООП, соблюдение которых не обязательно для разработки объектно-ориентированного программного обеспечения. Выделим наиболее важные дополнительные свойства объектно-ориентированного программирования.
Модульность - свойство системы, которая была разложена на внутренние связные, но слабо связанные между собой модули. Структура каждого модуля должна быть достаточно простой для понимания, допускать независимую реализацию других модулей и не влиять на поведение других модулей, а также позволять легкое изменение проектных решений.
Иерархия - ранжированная или упорядоченная система абстракций. Принцип иерархичности предполагает использование иерархий при разработке программных систем. В ООП используется два вида иерархии:
иерархия "целое/часть" - показывает, что некоторые абстракции являются частями других абстракций. Например, лампа состоит из цоколя, нити накаливания и колбы;
иерархия "общее/частное" - показывает, что некоторая абстракция является частным случаем другой абстракции. Например, "обеденный стол" - конкретный вид стола, а "стол" - конкретный вид мебели. Такая иерархия используется при разработке структуры классов, когда сложные классы строятся на базе более простых классов путем добавления к ним новых характеристик и, возможно, уточнения имеющихся. Реализуется с помощью иерархии наследования.
Типизация - это ограничение, накладываемое на свойства объектов и препятствующее взаимозаменяемости абстракций различных типов. Язык Java имеет строгую типизацию, когда для каждого программного объекта (переменной, функции, аргумента и так далее) объявляется тип, который определяет множество операций над соответствующим программным объектом.
Устойчивость - свойство абстракции существовать во времени (независимо от процесса, породившего данный программный объект) и в пространстве (перемещаясь из адресного пространства, в котором он был создан). Различают:
временные объекты - хранят промежуточные результаты некоторых действий, например, вычислений;
локальные объекты - существуют внутри методов, объект уничтожается после окончания работы метода;
глобальные объекты - существуют, пока программа загружена в память;
сохраняемые объекты - хранятся в файлах внешней памяти между сеансами работы программы.
Интерфейс и абстрактные классы улучшают структуру кода и способствует отделению интерфейса от реализации. В первую очередь необходимо рассмотреть понятие абстрактного класса, который является промежуточной ступенью между обычным классом и интерфейсом.
Рассмотрим еще раз пример с фигурами из темы про полиморфизм.
Методы базового класса Shape
всегда были "фиктивными". Попытка вызова метода из класса Shape
привела бы к ошибке в программе. Это было связано с тем, что класс Shape
был нужен лишь для того, чтобы определить общий интерфейс всех классов, производных от него, а уже производные классы переопределяли эти методы и реализовывали их по-своему.
Класс Shape
определял базовую форму, общность всех производных классов. Такие классы как Shape называют абстрактными базовыми классами или просто абстрактными классами.
Если в программе определяется такой абстрактный базовый класс вроде Shape
, создание объектов такого класса практически всегда бессмысленно. Абстрактный класс создается для работы с набором классов через общий интерфейс. А если Shape
только выражает интерфейс, а создание объектов такого класса не имеет смысла, лучше всего запретить пользователю создавать такие объекты, так как он может ненароком создать объект этого класса и попытаться с ним работать, что приведет к ошибкам в программе.
В языке Java для решения подобных задач используют механизм абстрактных методов. Абстрактный метод является незавершенным; он состоит только из объявления и не имеет тела. Синтаксис объявления абстрактных методов выглядит следующим образом:
Класс, содержащий хотя бы один абстрактный метод, называется абстрактным классом. Такой класс также должен помечаться ключевым словом abstract
(в противном случае, компилятор выдает сообщение об ошибке):
На уровне языка и компилятора создать экземпляр абстрактного класса невозможно
Если вы объявите класс, производный от абстрактного класса, но хотите иметь возможность создания новых объектов, то вы должны переопределить все абстрактные методы базового абстрактного класса. Если это не будет сделано, то производный класс тоже останется абстрактным, и компилятор заставит пометить новый класс ключевым словом abstract
.
Объявление класса как abstract
не подразумевает, что все его методы должны быть абстрактными.
Абстрактный класс может иметь другие, не абстрактные методы, поля и даже конструкторы.
Механизм абстрактных классов и методов очень полезен, так как он позволяет подчеркнуть абстрактность сущности, снижает риск возникновения ошибок в коде, а также сообщает пользователю и компилятору, как следует с ним обходиться.
Кроме того, абстрактные классы играют полезную роль при рефакторинге, потому что они позволяют легко перемещать общие методы вверх по иерархии наследования.
Ключевое слово interface
становится следующим шагом на пути к абстракции. Ключевое слово abstract
позволяет создать в классе один или несколько неопределенных методов - разработчик предоставляет часть интерфейса без реализации, которая должна предоставляться производными классами.
Ключевое слово interface
используется для создания классов, вообще не имеющих реализации. Создатель интерфейса определяет имена методов, списки аргументов и типы возвращаемы значений, но не тела методов. Интерфейс описывает форму, но не реализацию.
Начиная с Java 8, в язык были добавлены различные механизмы для интерфейса - методы по умолчанию, статические и приватные методы, а также константы.
Это, безусловно, облегчает повседневную жизнь разработчику, но затрудняет изучение Java. Поэтому мы будет рассматривать механизм интерфейсов "в чистом виде", без упоминания различных дополнительных возможностей.
Ключевое слово interface
фактически означает - именно так должны выглядеть все классы, которые реализуют данный интерфейс. Поэтому любой код, использующий конкретный интерфейс, знает только то, какие методы вызываются для этого интерфейса, но не более того. Интерфейс определяет своего рода "протокол взаимодействия" между классами.
Кроме этого, в отличие от абстрактного класса, интерфейс позволяет реализовать своего рода, множественное наследование.
Чтобы создать интерфейс, используйте ключевое слово interface
вместо class
. Как и в случае с классами, перед словом interface
указывается модификатор доступа. Интерфейс также может содержать поля, они автоматически являются статическими (static
) и неизменяемыми (final
).
Обратите внимание, что мы не указываем для методов модификатор - все методы интерфейса являются public
.
Для создания класса, реализующего определенный интерфейс (или группу интерфейсов), используется ключевое слово implements
. Фактически это означает "интерфейс определяет форму, а данный класс определяет, как это будет реализовано".
В классе, который реализует интерфейс, реализуемые методы должны быть объявлены как public
.
Неважно, приводите ли вы преобразование к "обычному" классу с именем Shape
, к абстрактному классу Shape
или к интерфейсу Shape
- действие будет одинаковым.
Когда метод работает с классом вместо интерфейса, мы ограничены использованием базового класса и его подклассами. Это исключает возможность использовать метод для класса, который не входит в эту иерархию. Интерфейс, в значительной степени ослабляет это ограничение. В результате код становится более универсальным.
В Java можно объявлять переменные ссылочного интерфейсного типа, то есть переменные, хранящие ссылки на интерфейс. Такая переменная может ссылаться на любой объект, реализующий ее интерфейсный тип. При вызове метода для объекта по интерфейсной ссылке выполняется вариант этого метода, реализованный в классе данного объекта. Этот процесс аналогичен применению ссылки на суперкласс для доступа к объекту подкласса.
Так как интерфейс по определению не имеет реализации, нет ничего что могло бы помешать совмещению нескольких интерфейсов. При объявлении класса, который совмещает несколько интерфейсов, имена интерфейсов перечисляются вслед за ключевым словом implements
и разделяются запятыми. Класс обязан предоставить реализацию для всех методов интерфейсов, которые он реализует.
Многие языки программирования, в том числе Java, позволяют наследовать интерфейс один от другого. Синтаксис наследования интерфейсов аналогичен синтаксису наследования классов. Когда класс реализует интерфейс, он обязан реализовать все методы, определенные по цепочке наследования интерфейсов.
Если класс включает в себя интерфейс, но не полностью реализует определенные в нем методы, он должен быть объявлен как abstract
Механизм обратного вызова широко распространен в программировании. При обратном вызове программист задает действия, которые должен выполнять всякий раз, когда происходит некоторое событие. Например, можно задать действие, которое должно быть выполнено после клика на кнопку или при выборе определенного пункта меню.
Приведем небольшой пример. В Java нам доступен класс Timer
, который используется для отсчета интервала времени.
Устанавливая таймер, мы задаем интервал времени и указываем, что должно произойти по его истечении. Как указать таймеру, что он должен делать по истечении времени?
В ООП для таких случаев существует механизм обратного вызова. Он заключается в том, что программист должен передать таймеру объект некоторого класса. После этого таймер вызывает один из методов данного объекта.
Разумеется, таймер должен знать, какой метод объекта он должен вызвать и должен иметь гарантию, что в классе объекта реализован этот метод. Для этого таймеру нужно указать объект класса, который реализует интерфейс ActionListener
. Этот интерфейс выглядит следующим образом
По истечении заданного интервала времени таймер обращается к объекту, вызывает метод actionPerformed()
и передает ему объект класса Event
(класс Event
описывает событие в Java).
Как мы видим, конструктор класса Timer
запрашивает задержку и объект, у которого будет вызван метод actionPerformed
. Создадим класс, который будет реализовывать интерфейс ActionListener
Как мы видим, данный класс ничего кроме реализации интерфейса не делает. То есть он нужен только для одной цели - он содержит метод, который будет вызван таймером после задержки. Такие классы и их объекты называют слушателем.
Слушатель - это объект, который как бы "слушает" события, которые происходят с другим объектом. Когда это "слушаемое" событие происходит, вызывается указанный в интерфейсе метод этого объекта.
Создадим объект слушателя TimerAction
и передадим этот объект таймеру
Запустим приложение и посмотрим на результат
Обратите внимание, что метод actionPerformed()
принимает на вход объект класса ActionPerformed
. При вызове метода actionPerformed()
, таймер передает в метод объект класса ActionEvent
, который содержит различную информацию о событии. Таким образом, мы можем запрограммировать те или иные действия в зависимости от параметров события. Рассмотрим еще один пример, на этот раз будем использовать кнопку.
Создадим объект окна, объект кнопки и добавим кнопку в окно
По умолчанию, при нажатию на кнопку ничего не происходит. По аналогии с таймером нам необходимо передать кнопке слушатель, который реализует определенный интерфейс. Кнопка - гораздо более сложный объект, чем просто таймер, поэтому в связи с кнопкой может произойти очень много событий. Для каждого типа событий кнопка принимает свой слушатель, который реализует свой определенный интерфейс
С помощью такого многообразия интерфейсов мы можем обработать самые разнообразные события, которые могут случиться с кнопкой.
В данном случае, нас интересует метод addActionListener(ActionListener l)
, который принимает на вход слушатель, который реализует интерфейс ActionListener
.
С помощью этого метода мы передаем кнопке объект класса ButtonListener
. Когда произойдет какое-то событие, кнопка обратится к переданному объекту и вызовет метод actionPerformed()
этого объекта.
Такая реализация в большинстве случаев нас устраивает. Но что, если нужно обработать какое-то специфическое событие и получить детальную информацию. Например, что если мы хотим, чтобы кнопка вела себя по разному при нажатии левой и правой кнопок мыши?
Для этого у кнопки необходимо вызвать метод addMouseListener(MouseListener l)
и передать ему слушатель, который реализует интерфейс MouseListener
. Таким образом, мы обрабатываем не просто некоторое событие, а событие мыши. Событие мыши - более специфическое событие и поэтому нам доступна большая информация о событии.
Создадим слушатель, который реализует интерфейс MouseListener
Обратим внимание на две особенности:
1) интерфейс MouseListener
определяет уже несколько методов, а не один, как ActionListener
. Как видите, мы можем запрограммировать реакцию на очень специфические события, например, если курсор мыши вошел в область, которую занимает кнопка. Так как нас интересует только события клика, все остальные методы у нас имеют пустую реализацию. То есть, например, курсор мыши войдет в область кнопки, то кнопка вызовет пустой метод mouseEntered()
и ничего не произойдет (поищите информацию о классе MouseAdapter
, который упрощает работу с событиями мыши);
2) в методы передается объект класс MouseEvent
. Объект класса MouseEvent
обладает информацией о событии мыши, что дает нам возможность узнать о событии много подробностей.
Мы видим, что мы можем, например, с помощью метод getButton()
узнать - какой кнопкой было произведено нажатие, с помощью методов getX()
и getY()
узнать координаты нажатия и так далее. Создадим объект слушателя MouseListener
и передадим этот объект кнопке
Запустим приложение и нажмем кнопку левой кнопкой мыши.
Обобщение означает параметризированный тип. Обобщения позволяют создавать классы, интерфейсы и методы, в которых тип данных указывается в виде параметра. С помощью обобщений можно создавать класс, который будет автоматически работать с разными типами данных. Такие классы, интерфейсы и методы называются обобщенными, как например, обобщенный класс или обобщенный метод.
Обобщенный код будет автоматически работать с типом данных, переданным ему в качестве параметра. Многие алгоритмы выполняются одинаково, независимо от того, к данным какого типа они будут применяться. Например, сортировка не зависит от типа данных, будь то String
, Student
или любой другой пользовательский класс, объекты которого можно сравнить между собой. Используя обобщения, можно реализовать алгоритм один раз, а затем применять его без дополнительных усилий к любому типу данных.
Рассмотрим небольшой пример. Рассмотрим класс Box
, который может содержать в себе один объект.
В данном случае наша коробка хранит объекты класса String
. Но что, если нам в нашей программе необходима коробка, которая хранила бы не только объекты класса String
, но и любого другого класса, как стандартного из библиотек Java, так и нашего созданного? Как мы можем реализовать это с помощью уже известных механизмов ООП?
Мы можем использовать механизм полиморфизма. Например, мы можем использовать класс Object
, который, как известно, является суперклассом для всех классов Java.
Второй вариант - объявить интерфейс BoxItem
и использовать его в качеcтве типа ссылочной переменной. Тогда в коробку можно будет "положить" объект класса, который реализует этот интерфейс.
Метод стал чуть более общим и может использоваться в большем количестве мест. Однако, огромный недостаток такого подхода - необходимость нисходящего преобразования извлеченного из коробки объекта. Если же мы не знаем заранее, объект какого класса будет помещен в коробку, то и нисходящее преобразование реализовать будет достаточно затруднительно.
Какой выход может быть из данной ситуации? В Java и других ОО-языках программирования существует механизм обобщенных типов. Этот механизм позволяет нам создавать классы, методы и интерфейсы, которые будут автоматически работать с типами данных, которые будут переданы позднее, при создании объекта этого класса.
Реализуем класс Box
с помощью механизма обобщенных типов
Такой класс называется обобщенным классом. Под термином обобщение следует понимать "применимость к большой группе классов". Создадим объект класса Box
После создания объекта обобщенного класса, вместо T
будет подставлен тип Item
То есть, нам нет необходимости заниматься нисходящим преобразованием и приведением типов - объект класса Box
будет работать с классом Item
.
Когда объявляется экземпляр обобщенного типа, аргумент передаваемый параметру типа должен быть ссылочным типом. Использовать для этого примитивные типы, например, int
или char
, нельзя.
Для преодоления этой проблемы в Java предусмотрен механизм автоупаковки (autoboxing) и автораспаковки (unboxing), который позволяет использовать классы-оболочки типов данных (их еще называют "обертки", wrapper).
В пятой версии Java были добавлены два очень полезных механизма - автоупаковка и автораспаковка, которые существенно упрощающих и ускоряющих создание кода, в котором приходится преобразовывать простые типа данных в объекты и наоборот.
Как мы знаем, в Java предусмотрены 8 примитивных типов данных. Примитивные типы данных имеют преимущества по сравнению с объектами, но механизм обобщенных типов и многие структуры данных в Java предполагают работу с объектами и поэтому в них нельзя хранить данные простых типов.
Для решения этой проблемы в Java предусмотрены классы-обертки (wrappers). Классы-обертки реализуются в классах Double
, Float
, Long
, Integer
, Short
, Byte
, Character
и Boolean
. Все обертки числовых типов данных являются производными от абстрактного класса Number
.
Процесс преобразования значения примитивного типа в объект соответствующего класса-обертки называется упаковкой (boxing).
Процесс извлечения значения примитивного типа из объекта-обертки называется распаковкой (unboxing).
Автоупаковка - процесс автоматической упаковки (инкапсуляции) простого типа данных в объектную обертку без необходимости явного создания объекта.
Автораспаковка - это обратный процесс автоматической распаковки (извлечения) значения, упакованного в объектную оболочку.
Количество типов, которые можно передать в обобщенный класс, не ограничено. Чтобы передать несколько типов, нужно перечислить из через запятую. Синтаксис объявления ссылки и создания такого объекта аналогичен созданию объекта с одним параметров.
Очень часто при использовании обобщений, необходимо ограничить типы, которые могу быть переданы классу. Например, при написании обобщенного класса, который выполняет операции с числами, необходимо указать, что в качестве типа можно передать тип Number
.
Для этого необходимо после placeholder написать ключевое слово extends
и указать имя класса (в этом случае будут доступны объекты этого типа или типов-наследников) или интерфейса (объекты типов, которые реализуют интерфейс).
Если класс принимает несколько типов, то мы можем указать один тип в качестве ограничителя второго. Таким образом, можно гарантировать, что оба класса совместимы друг с другом.
Для того, чтобы иметь возможность использовать обобщенный класс в качестве аргумента метода, необходимо использовать так называемый "шаблон аргумента" (wildcard).
Предположим, нужно реализовать метод, который сравнивает два объекта класса NumericValue
и возвращает true
, если оба объекта равны.
Необходимо сообщить компилятору, что входным аргументом является объект обобщенного класса. Написать NumericValue<T>
не получится, так как это не объявление класса, а записать какое-то конкретное значение мы не можем, так как мы хотим подать объект обобщенного типа с любым допустимым типом данных. Для такого случая используется специальный символ ?
, который и является шаблоном аргумента.
Такая сигнатура метода означает, что метод isEquals()
принимает на вход аргументы обобщенного типа NumericValue
, где параметр типа может быть любым допустимым для типа NumericValue
.
Кроме "ограничения сверху", мы можем устанавливать "ограничение снизу", то есть установить в качестве корректного типа этот тип и суперклассы выше по цепочке наследования. Это реализуется с помощью ключевого слова super
.
Методы в обобщенных классах могут использовать параметр типа своего класса, а следовательно, автоматически становятся обобщенными относительно параметра класса. Однако можно объявить обобщенный метод, который сам по себе использует параметр типа. Более того, такой метод может быть объявлен в обычном, а не обобщенном классе.
К примеру, реализуем методы, который будет сравнивать два массива обобщенных типов. Сначала объявим два класса
Далее объявим метод compareArrays()
Выражение T extends Comparable<T>
означает, что тип T
должен реализовывать обобщенный интерфейс Comparable<T>
, то есть чтобы мы могли сравнить объект типа T
с другим объектом типа T
. Выражение V extends T
означает, что тип V
должен быть или типом T
или производным от T
типом.
Создадим два массива и попытаемся вызвать метод compareArrays()
.
Такой код вызовет ошибку компиляции, так как тип Student
не реализует интерфейс Comparable<Student>
.
Исправим данную ошибку
Если мы перепишем класс PostGradStudent
так, чтобы он не является наследником типа Student
, то при исполнении кода получим следующую ошибку
Так как конструктор является методом, то мы также можем объявлять обобщенные конструкторы. Обобщенный конструктор можно объявить даже тогда, когда сам класс не является обобщенным.
Обобщенный интерфейс объявляется также как и обобщенный класс
Если класс реализует обобщенный интерфейс, то он также должен быть обобщенным. Не обобщенным он может быть только в том случае, если вы явно указываете тип при объявлении класса.
В интерфейсе можно указать ограничение с помощью ключевого слова extends
. Класс, который реализует обобщенный интерфейс с ограничением, также должен соблюдать эти ограничения. При объявлении класса ограничение следует прописать после названия класса, но после ключевого слова implements
его дублировать не надо.
Согласно общепринятым правилам и конвенции кода, параметры типов записываются в виде одного символа алфавита в верхнем регистре. Рекомендуется использовать следующие символы алфавита:
E (означает Element, часто используется в коллекциях);
K (означает Key);
N (означает Number);
T (означает Type);
V (означает Value);
S, U - обычно вторые, третьи, четвертые параметры типа.
Полиморфизм является второй важной особенностью объектно-ориентированных языков.
Он представляет еще одну степень отделения интерфейса от реализации, разъединения что от как. Полиморфизм улучшает организацию кода и его читаемость, а также способствует созданию расширяемых программ, который могут "расти" не только в процессе начальной разработки проекта, но и при добавлении новых возможностей.
Чтобы начать рассматривать принцип полиморфизма, рассмотрим механизм преобразования типов в Java.
Преобразование типов позволяет преобразовывать данные одного типа в другой тип. Можно выделить следующие типы преобразования:
расширяющее преобразование (widening) - значение одного типа преобразовывается в более широкий тип, с большим диапазоном допустимых значений, то есть не происходит потери данных.
сужающее преобразование (narrowing) - значение одного типа преобразовывается в более узкий тип, с меньшим диапазоном допустимых значений, в этом случае возможна потеря данных.
упаковка\распаковка данных (boxing\unboxing) - позволяет преобразовывать данные примитивных типов в соответствующие им объекты типов-оболочек.
Про упаковку и распаковку данных мы будем говорить позже, а пока рассмотрим примеры расширяющие и сужающего преобразования.
Результат работы программы будет следующим
Как мы видим из приведенного фрагмента кода, расширяющее преобразование происходит неявно, автоматически. Конечно, нам никто не запрещает указать явное преобразование
но Java этого не требует и такая форма записи при расширяющие преобразовании не используется.
Сужающее преобразование можно привести к потере данных и поэтому Java требует явного преобразования типов, запись типа
вызовет ошибку компиляции.
Наряду с преобразованием данных примитивных типов, Java поддерживает преобразование ссылочных типов.
Преобразование примитивных типов базировалось на количестве байт, который использовал тот или иной тип данных и на возможность хранить больший диапазон допустимых значений. Преобразование ссылочных базируется на иерархии классов.
Класс-родитель считается базовым типом, потому что все объекты класса-потомка входят во множество объектов базового класса. Как мы уже говорили раньше, все объекты Student
являются объектами Person
(все студенты являются людьми), все объекты Dog
являются объектами Animal
(все собаки являются животными).
Каждый класс Java расширяет (наследуется) другой класс, который называется суперклассом. Класс наследует поля и методы суперкласса и определяет свои дополнительные поля и методы. Существует специальный класс Object
, который является корнем классовой иерархии. Он не наследуется от каких-либо классов, но все другие классы Java либо напрямую расширяют класс Object
, либо наследуются от одного из потомков Object
.
Рассмотрим классы String
и Point
. Мы можем сказать, что все объекты String
также являются объектами Object
. Также мы можем сказать, что все объекты Point
являются объектами Object
. Обратное утверждение неверно (не все объекты Object
являются объектами Point
или String
).
Опишем правила преобразования ссылочных типов в Java:
1.Объект не может быть преобразован в несовместимый (unrelated) тип. Речь может идти только о классах с отношением "родитель" - "потомок". К примеру, Java не сможет преобразовать объект типа String
в объект типа Point
. Даже если вы будете использовать явное преобразование, то следующий код
приведет к ошибке компиляции
2. Объект может быть преобразован в тип суперкласса. Это расширяющее преобразование, поэтому Java не требует явного преобразования. Например, ссылка на объект типа String
может быть присвоена переменной типа Object
, так как String
наследуется от класса Object
, напрямую или опосредовано. Ссылка на объект типа Point
также может быть присвоена переменной типа Object
. Хотя класс Point
напрямую наследуется от Point2D
, класс Object
все равно является суперклассом для Point
, хотя и опосредовано. К примеру, следующий код вполне корректен и не вызовет ошибок
Важнейший момент - при преобразовании ссылочных типов, сам физический объект ни во что не преобразовывается. Просто теперь мы можем работать с ним как будто он объект типа Object.
Это замечание - важнейший момент, который вам необходимо усвоить. В примере выше переменные o1
и o2
по факту будут ссылаться на объекты типа String
и Point
соответственно. При любых преобразованиях типов, сам объект никак не модифицируется, просто Java теперь считает его объектом другого типа. К чему это приводит - рассмотрим позже.
3. Объект может быть преобразован в тип подкласса. Это является сужающим преобразованием и требует явного приведения типов.
Опасность сужающего преобразования ссылочных типов заключается в том, что компилятор Java не может определить корректность преобразования во время компиляции, эта проверка происходит во время выполнения (в runtime, в рантайме) приложения. Это может привести к ошибке во время выполнения и закрытию приложения, что недопустимо при разработке качественного ПО.
Сужающее преобразование будет корректным только в одном случае - если объект по факту является объектом этого класса!
Нисходящее преобразование является корректным только когда это операция, обратная восходящему преобразованию.
Например, данный код
приведет к ошибке во время выполнения
Ошибка возникает потому что ссылочная переменная person
ссылается на объект, который имеет фактический тип Person
. При попытке преобразовать его в объект типа Student
произойдет ошибка преобразования типов.
Но следующий код
будет работать корректно и преобразование будет корректным.
В примере выше мы сначала сделали восходящее преобразование
и переменная person
ссылается на объект, который имеет фактический тип Student
. Реализуя сужающее преобразование
мы ставим все на свои места - ссылочная переменная типа Student
ссылается на объект фактического типа Student
.
Еще раз повторим, нисходящее преобразование используется, чтобы восстановить тип объекта после восходящего преобразования.
4. Все типы массивов являются несовместимыми типами, поэтому массив одного типа не может быть преобразован в массив другого типа, даже если элементы массивов могут быть преобразованы. Например, следующий код
приведет к ошибке во время выполнения, хотя отдельные элементы типа int
можно преобразовать в double
.
5. Массивы не имеют иерархию типов, но все массивы считаются экземплярами типа Object, поэтому любой массив может быть преобразован в тип Object
с помощью расширяющего преобразования и обратно с помощью сужающего преобразования.
Для начала, вернемся к наследованию. Самая важная особенность наследования заключается в том, что наследование выражает отношение между новым и базовым классом. Это отношение можно выразить как "Новый класс является разновидностью базового класса", это же справедливо и для объектов этих классов. Например, класс Student
является разновидностью Person
, класс Bear
является разновидностью Animal
и так далее.
Данное отношение поддерживается языком программирования. Например, рассмотрим базовый класс Instrument
, который представляет музыкальный инструмент, и класс Guitar
, который представляет гитару.
Так как наследование означает, что все методы базового класса также доступны в производном классе, любое сообщение, которое мы можем отправить базовому классу, можно отправить и производному классу. Если в классе Instrument
есть метод play()
, то он будет присутствовать и в классе Guitar
. Рассмотрим следующий код
В результате получим следующие сообщения в консоли
Обратите внимание, что на вход методу tune()
можно подать как объект типа Instrument
, так и объект типа Guitar
.
Таким образом, метод tune()
можно применять для объектов типа Instrument
и объектов любых классов, производных от Instrument
.
Как уже было сказано выше, мы имеет дело с расширяющим преобразованием ссылочных типов (преобразование ссылки на объект Guitar
в ссылку на объект Instrument
). Расширяющее преобразование ссылочных типов называется восходящим преобразованием типов (upcasting).
Рассмотрим еще раз пример с классом Instrument
Метод tune()
получает ссылку на объект класса Instrument
, но мы можем передать объект любого класса, производного от Instrument. В методe main()
ссылка на объект класса Guitar
передается методу tune()
без явных преобразований. Это нормально - интерфейс класса Instrument должен существовать и в классе Guitar
, так как класс Guitar
был унаследован от класса Instrument
.
Казалось бы, зачем нам указывать, что метод tune()
принимает ссылку на объект класса Instrument
? Если мы хотим, чтобы метод tune()
принимал ссылку на объект класса Guitar
, почему просто не написать
В таком случае, для каждого класса, производного от Instrument
придется писать свой метод tune()
. Предположим, что в новой версии программы мы добавили классы Saxophone
и Violin
. В таком случае у нас получился бы следующий код
Программа будет работать корректно, но у нее есть огромный недостаток - для каждого нового подкласса Instrument
необходимо будет писать свой новый метод tune()
, который будет принимать объект этого производного класса.
Если же указать в методe tune()
ссылку на базовый класс, тогда необходимо будет написать всего один единственный метод tune()
.
Таким образом, код метода tune()
будет, своего рода, обобщенным кодом, который будет выполняться не только для объектов базового класса, но и для объектов производного класса. В этом и заключается главное преимущество полиморфизма.
Важно понять, что в зависимости от разных объектов разных производных классов, метод tune()
может работать по-разному, так как в каждом подклассе мы переопределяем метод play()
.
В данном случае, методы play()
различных подклассов возвращают различную строку, поэтому, хотя и вызывается один и тот же метод tune()
, но результат его работы каждый раз разный, в зависимости от того - объект какого подкласса мы ему передали.
Давайте еще раз взглянем на метод tune()
Мы уже разобрались, что в зависимости от ссылки на объект того или иного подкласса, Java вызовет тот или иной переопределенный метод play()
. Но откуда компилятор знает - какой из методов play()
необходимо будет вызвать, ведь в качестве входного аргумента у нас указана ссылка на объект класса Instrument
? Ответ заключается в том, что компилятор этого не знает.
Присоединение вызова метода к телу метода называется связыванием. Если связывание производится перед запуском программы (на этапе компиляции или компоновки), оно называется ранним (early) или статическим (static) связыванием (binding).
Неоднозначность в работе метода tune()
связана именно с ранним связыванием: компилятор не может знать заранее, какой вариант метода play()
нужно будет вызвать, когда у него есть только ссылка на объект класса Instrument
.
Данная проблема решается благодаря позднему связыванию, то есть связыванию, проводимому во время выполнения программы, в зависимости от типа объекта. Позднее (late) связывание (binding) также называют динамическим (dynamic) или связыванием на этапе выполнения программы (runtime binding).
В реализации Java существует механизм для фактического определения типа объекта во время работы программы для вызова подходящего метода. Иначе говоря, компилятор не знает тип объекта, но механизм вызова метода определяет фактический тип объекта и вызывает соответствующее тело метода.
Для всех методов Java используется механизм позднего связывания, если только метод не был объявлен как final
(приватные методы являются final
по умолчанию).
Итак, подведем итоги:
статическое связывание в Java происходит на этапе компиляции, тогда как динамическое связывание происходит во время выполнения программы (в runtime);
для private
,
и finalstatic
методов, а также для полей используется статическое связывание, тогда как для остальных методов (такие методы в некоторых языках программированию называются виртуальными (virtual
)) используется динамическое связывание;
в статическом связывании используется тип ссылки, тогда как в динамическом связывании используется фактический тип объекта;
перегруженные методы используют статическое связывание, тогда как переопределенные методы используют динамическое связывание.
Рассмотрим популярный пример с геометрическими фигурами. Создадим базовый класс Shape
и различные производные классы: Circle
, Square
и Triangle
.
Создадим объект класса Circle
используя принцип восходящего преобразования
В данном коде создается объект типа Circle
, после чего ссылка на объект присваивается переменной типа Shape
. На первый взгляд это ошибка, мы не можем присвоить ссылочной переменной одного типа ссылку на объект другого типа. Но на самом деле, все правильно, потому что тип Circle
является типом Shape
посредством наследования.
Если мы вызовем у объекта метод draw()
то можно подумать, что вызовется метод draw()
из класса Shape
, так как ссылочная переменная имеет тип Shape
. Но на самом деле будет вызван правильный метод Circle.draw()
, так как в программе используется позднее связывание (полиморфизм).
Создадим объекты других типов
и посмотрим на результат
Базовый класс Shape устанавливает для всех классов, производных от Shape, общий интерфейс - набор публичных методов (действий, которые может совершить внешний код над объектом). То есть любую фигуру можно нарисовать (draw()
) и стереть (erase()
). Производные классы переопределяют этот набор методов, чтобы реализовать свое уникальное поведение для этой фигуры.
Благодаря полиморфизму, вы можете добавлять сколько угодно новых типов, внося в программу минимальные изменения или не внося изменений вовсе. В хорошо спроектированной программе, большая часть методов (или даже все методы) переопределяют интерфейс базового класса. Такая программа будет являться расширяемой, поскольку в нее можно добавлять дополнительную функциональность, создавая новые типы данных из общего базового класса.
Возможность повторного использования кода принадлежит к числу важнейших преимуществ языков объектно-ориентированного программирования.
В Java, вместо того чтобы создавать новый класс "с чистого листа", вы берете за основу уже существующий класс, который кто-то уже создал и проверил на работоспособность. Существую два пути реализации этой идеи.
композиция (composition) - объекты уже имеющихся классов просто создаются внутри нового класса. Программист просто использует функциональность уже готового кода;
наследование (inheritance) - новый класс создается как специализация уже существующего класса. Взяв существующий класс за основу, вы добавляете к нему свой код без изменения существующего класса.
Композиция - использование функционала одних объектов в составе других объектов. Рассмотрим пример класса FileManager
, в котором определен метод для сохранения текстовых данных в файл. Класс Document
использует функционал класса FileManager
, чтобы сохранить текстовый документ на жесткий диск.
Наследование - отношение между классами, в котором один класс повторяет структуру и поведение другого класса (или нескольких других классов).
Реализуется наследование путем создания классов на основе уже существующих. При этом члены класса, на основе которого создается новый класс, с некоторыми оговорками, автоматически включается в новый класс. Кроме этого, в новый класс можно добавлять новые члены.
Класс, на основе которого создается новый класс, называется суперклассом (базовым классом, родительским классом). Новый создаваемый класс называется подклассом (дочерним классом, производным классом, классом-наследником и так далее).
Создание подкласса практически не отличается от создания обычного класса, кроме необходимости указать суперкласс, на основе которого создается подкласс. В Java для этого существует ключевое слово extends:
В Java, в отличие от C++, отсутствует множественное наследование, то есть подкласс может создаваться на основе только одного суперкласса.
В Java присутствует многоуровневое наследование: подкласс может быть суперклассом для другого класса. Благодаря этому можно создавать целые цепочки классов, связанные механизмом наследования
Если член класса определен как private
, то при наследовании доступ к нему со стороны подкласса закрыт. Важно понимать, что приватный член суперкласса в подклассе есть, только он закрыт для прямого доступа. К примеру, данный код не скомпилируется
Закрытыми могут быть как поля класса, так и его методы. Если необходимо открыть поля или методы для доступа к ним со стороны подкласса, при объявлении членов суперкласса используют слово protected
либо создают геттеры и сеттеры для доступа к полям. К примеру, данный код скомпилируется и будет работать корректно
Данный пример демонстрирует доступ к полям с помощью геттеров
Так как в наследовании участвуют два класса, базовый и производный, не сразу понятно, какой же объект получится в результате. Внешне все выглядит так, словно новый класс имеет тот же интерфейс, что и базовый класс, плюс еще несколько дополнительных полей и методов.
Однако наследование не просто копирует интерфейс базового класса. Когда вы создаете объект производного класса, внутри него содержится подобъект базового класса. Этот подобъект выглядит точно так же, как выглядел бы созданный обычным порядком объект базового класса. Поэтому извне представляется, будто бы в объекте производного класса "упакован" объект базового класса.
Чтобы подобъект базового класса был правильно инициализирован, при вызове конструктора подкласса, сначала вызывается конструктор базового класса, у которого есть необходимые знания и привилегии для проведения инициализации базового класса.
При использовании конструкторов без параметров, у компилятора не возникает проблем с вызовом таких конструкторов, так как нет нужды передавать аргументы. В этом случае Java автоматически вставляет вызовы конструктора базового класса в конструктор производного класса.
Результат работы такого приложения будет следующим
Как видно из данного примера, цепочка вызовов конструкторов начинается с самого базового класса. Таким образом, подобъект базового класса инициализируется еще до того, как он станет доступным для конструктора производного класса. Даже если конструктор класса Cat
не будет определен, Java сгенерирует конструктор по умолчанию, в котором также будет вызван конструктор базового класса.
Если в классе не определен конструктор без параметров, то вызов конструктора базового класса надо будет оформлять явно. К примеру, такой код вызовет ошибку на этапе компиляции
Для явного вызова конструктора суперкласса используется ключевое слово super. Более подробно мы рассмотрим его ниже, а сейчас приведем пример корректного вызова конструктора суперкласса
Как было сказано ранее, наследование в Java реализуется следующим образом - в объект производного класса добавляется скрытый объект базового класса, который и обеспечивает вызов методов суперкласса.
Ключевое слово super
как раз и ссылается на этот скрытый объект суперкласса. Используя это ключевое слово, можно получить доступ к членам суперкласса (если позволяет их модификатор доступа)
Как видно из примера, ключевое слово super
имеет что-то общее с ключевым словом this
.
При использовании механизма наследования возникает проблема с использованием методов суперкласса. Часто метод суперкласса не отражает изменения и нововведения, внесенные в подклассе и вызов таких методов дает некорректную информацию об объекте. Рассмотрим следующий пример
Результат работы такого приложения будет следующим
Как вы уже понимаете, в данной части кода
был вызван метод getInfo()
суперкласса, который выводит информацию только о двух параметрах коробки, тогда как класс Box3D
содержит три параметра. Таким образом, метод getInfo()
для класса Box3D
становится как бы некорректным, неправильным, он выдает неполную информацию об объекте. Как решить эту проблему?
Первый вариант - создать в подклассе Box3D
свой метод для вывода информации
Тогда мы будем вызывать метод get3DInfo()
что даст нам корректный результат
Такой вариант является интуитивно понятным, но все-таки не совсем корректным. Теперь у объекта Box3D
существует аж целых два метода для получения информации об объекте, один из которых является некорректным, что вносит путаницу для нас и для тех программистов, которые будут использовать наш код.
Самым правильным вариантом будет как бы "переписать" метод getInfo(), предоставить версию метода getInfo() для класса Box3D
. Таким образом и класс Box
и класс Box3D
будет иметь метод getInfo()
, просто в классе Box3D
он будет иначе реализован, с учетом появления третьего параметра. Кроме того, объект класса Box3D
теперь будет содержать только один метод для получения информации об объекте и этот метод будет корректно работать.
Для реализации своеобразного "переписывания" метода в подклассе, в Java существует механизм переопределения метода (method overriding).
Воспользуемся механизмом переопределения метода, чтобы корректно решить проблему с классами Box
и Box3D
(в примере для наглядности опущены некоторые члены классов)
Обратите внимание что сигнатуры двух методов getInfo()
полностью совпадают - это обязательное условие для срабатывания механизма переопределения метода. Метод в суперклассе называется переопределенным.
Также обратите внимание на строку 33, где записано @Override
. Такая запись называется аннотацией.
Аннотация - это дополнительное пояснение для компилятора и для различных утилит, которые работают с кодом (анализаторы, генераторы документации и так далее). Указание аннотации не является обязательным, код будет работать и без аннотации, но указание аннотации является одним из важных правил грамотного написания кода.
Результат работы нашего примера будет следующим
Обратите внимание, что и для класса Box
и для класса Box3D
мы вызываем метод с одним и тем же названием и с одной и той же сигнатурой
Но в зависимости от того, для какого класса мы вызываем этот метод, в первом случае отрабатывает метод в классе Box
, а во втором случае - метод в классе Box3D
.
Следует отличать механизм перегрузки метода от механизма переопределения метода.
Метод может быть как перегруженным, так и переопределенным.
Иногда бывает необходимо запретить наследоваться от какого-то класса либо запретить переопределять метод. В этом случае, в объявлении класса или метода укажите ключевое слово final
В Java определен специальный класс Object
, который является суперклассом для всех классов Java. Иными словами, все классы в языке Java являются подклассами, производными от класса Object
.
В классе Object определены перечисленные ниже методы, которые доступны в любом объекте
С некоторыми методами класса Object
мы встретимся позже, а сейчас нам интересен только метод toString()
.
Метод toString()
призван возвращать строковое представление объекта (список значений полей). Рассмотрим пример
Обратите внимание, что класс Box
не содержит никаких членов, однако, так как Box наследуется от класса Object
, ему доступны методы суперкласса и метод toString()
в частности.
Результатом работы данного примера является следующая строка
Метод toString()
в классе Object
выводит полное название класса и 16-ричное представление хеш-кода объекта.
Если вам необходимо вывести значения полей объекта в виде строки, используйте метод toString()
, он для этого и был предназначен, его использование является общепринятым правилом.
Метод getInfo()
в примере выше был использован только в демонстрационных целях!
Чтобы метод toString()
выводил нужную информацию, его необходимо пепреопределить как в следующем примере
Результат будет следующим
Также метод toString()
имеет одну примечательную особенность - его можно явно не вызывать, а просто указывать ссылочную переменную, Java сама вызовет метод toString()
. Например, следующий код
выдаст следующий результат
Как вы видите, результат работы строк 5 и 6 является идентичным.
И композиция, и наследование позволяют вам помещать подобъекты внутрь вашего нового класса (при композиции это происходит явно, а в наследовании - опосредовано). В чем же разница между ними и когда следует выбирать одно, а когда другое?
Композиция в основном используется, когда в новом классе необходимо задействовать функциональность уже существующего класса, но не его интерфейс. То есть вы встраиваете объект, чтобы использовать его возможности в новом классе, а пользователь класса видит только определенный вами интерфейс, но не замечает встроенных объектов. Для этого внедряемые объекты объявляются со спецификатором private.
При использовании наследования, вы берете уже существующий класс и создаете eго специализированную версию. В основном это значит, что класс общего назначения адаптируется для конкретной задачи.
Неплохая статья по поводу отличия композиции и наследования - https://habr.com/ru/post/325478/
Вайсфельд, стр. 131 - 144;
Блох, стр. 125 - 131.
Обработка событий играет важную роль, поскольку большинство элементов управления GUI генерируют события, которые обрабатываются программой. Когда вы используете кнопки, флажки или списки, все они генерируют события.
Базовым классом событий JavaFX является класс Event
, находящийся в пакете javafx.event
. Класс Event
наследует класс java.util.eventObject
, а это значит, что события JavaFX разделяют общую функциональность с другими событиями Java. Для класса Event
определено несколько подклассов, каждый из которых отвечает за свой тип событий.
JavaFX различает много видов событий, среди которых, например, DragEvent
, KeyEvent
, MouseEvent
, ScrollEvent
и другие.
Чтобы среагировать на нажатие кнопки, вам необходимо написать код для обработки этого события. В данном случае, кнопка называется источник события (event source object) – объект, где произошло событие.
Само по себе событие является объектом соответствующего класса (наследуемого от javafx.event
) и называется объектом события (event object). Чтобы обработать событие, необходимо создать объект, который может обработать это событие. Такой объект называется обработчик событий (event handler).
Не все объекты могут быть обработчиками события. Чтобы быть обработчиком action event, необходимо выполнить два требования:
объект должен быть экземпляром класса, который реализует интерфейс EventHandler<T extends Event>
. Этот интерфейс определяет общее поведение для всех обработчиков. Как мы помним, <T extends Event>
означает, что T
– обобщенный тип, который является классом или подклассом Event
;
обработчик события должен быть зарегистрирован источником события с помощью метода setOnAction()
.
Интерфейс EventHandler<T extends Event>
содержит метод handle(T event)
для обработки события. Ваш обработчик должен переопределить метод, чтобы среагировать на событие. Рассмотрим небольшой пример
На данный момент вы уже должны понимать содержимое метода start()
. Если мы нажмем на одну из кнопок, то ничего не произойдет, т.к. по умолчанию, никакой реакции на нажатие не предусмотрено – реакцию должны прописать мы.
Для этого мы должны создать объект класса, который реализует интерфейс EventHandler<T extends Event>
и передать этот объект кнопке. Передать кнопке объект обработчика событий мы можем двумя способами:
использовать метод addEventHandler()
, которому нужно передать тип события, а также объект обработчика события;
использовать один из множества методов setOn***()
, каждый из которых позволяет установить обработчик для того или иного события.
Событие нажатия на кнопку относится к типу ActionEvent
. Для каждого элемента GUI событие ActionEvent
означает свой тип события, как правило, событие центральное, наиболее значимое для этого элемента. Например, для кнопки основное событие – когда на нее нажали. Именно за это нажатие и отвечает тип ActionEvent
.
Используем для каждой кнопки один из способов задания обработчика события
Результат работы выглядит следующим образом:
Важное отличие двух способов состоит в том, что в случае использования метода addEventHandler()
мы можем назначить более одного обработчика на то или иное событие. Например, в следующем примере будут выполнены сразу два метода двух обработчиков
Механизм обработки событий
Кратко, описать механизм обработки событий можно следующим образом: в JavaFX обработка событий выполняется по цепочке диспетчеризации событий. Когда событие генерируется, оно передается сначала корневому узлу, а затем вниз по цепочке адресату события. После обработки события в узле адресата, оно передается обратно вверх по цепочке, предоставляя возможность родительским узлам обработать его по мере надобности. Такой механизм распространения событий называется всплыванием событий. Он позволяет любому узлу цепочки «поглотить» (consume) событие, чтобы оно больше не обрабатывалось.
Теперь разберемся более подробно.
Каждое событие (объект класса javafx.event.Event
либо его подкласса) имеет три свойства:
тип события (event type);
источник события (event source);
цель события (event target).
Соответственно, класс Event
предоставляет методы, общие для всех объектов события
Подклассы класса Event
содержат дополнительную информацию, которая описывают конкретный тип события. Например, класс MouseEvent
определяет методы getX()
и getY()
, которые возвращает координаты курсора мыши относительно источника события.
Тип события.
Тип события является экземпляром класса EventType
. Все типы событий являются экземплярами этого класса. Например, класс KeyEvent
содержит следующие типы событий:
KEY_PRESSED
;
KEY_RELEASED
;
KEY_TYPED
.
Типы событий организованы в иерархию. Каждый тип событий имеет имя и «супер-тип». Например, имя типа, который отвечает за нажатие кнопки клавиатуры называется KEY_PRESSED
, а супер-типом является KeyEvent.ANY
.
Самым верхним уровнем типа событий в иерархии является Event.ROOT
, который идентичен типу Event.ANY
. Тип событий ANY
означает любое событие этого типа. К примеру, указав тип события KeyEvent.ANY
, мы можем обработать любое событие, связанное с нажатием клавиши на клавиатуре. Если же вы хотите обработать только событие, связанное с «отжатием» клавиши, используйте тип KeyEvent.KEY_RELEASED
.
Цель события
Цель события – элемент управления, при взаимодействии с которым было сгенерировано событие. Например, если пользователь нажмет на кнопку – целью события является объект кнопки. Если пользователь передвинет мышку, и курсор в этот момент будет находиться над GridPane
, то целью будет являться GridPane
.
Целью события может быть любой объект, который реализует интерфейс EventTarget
. Классы Window
, Scene
и Node
реализуют интерфейс EventTarget
.
Механизм обработки событий
Механизм обработки событий состоит из четырех этапов:
выбор цели события (target selection);
построение маршрута события (route construction);
захват события (event capturing);
всплытие события (event bubbling).
1) Выбор цели.
Когда происходит событие, система определяет, какой узел является целью объекта события (event target). Правила определения цели следующие:
· для событий, связанных с клавиатурой, целью является узел, у которого в данный момент есть фокус (что такое фокус – читайте ниже);
· для событий, связанных с мышью, цель это узел, который находится под курсором мыши;
Остальные правила связаны с касаниями и жестами и в данном курсе не рассматриваются (ссылка - https://goo.gl/hihl8G, раздел «Target Selection»).
Если под курсором мыши расположено больше одного узла, тогда целью считается самый верхний узел.
2) Построение маршрута события
Маршрут события строится объектом цели события с помощью метода buildEventDispatchChain(), который вызывается у цели событий. Последовательность объектов, которые должен обойти объект события называется event dispatch chain. Рассмотрим пример:
Цепочка элементов выглядит следующим образом
Когда мы кликнем на объект класса Circle, произойдет следующая последовательность действий:
1) Среда Java создает объект события;
2) Среда вычисляет, что целью события является объект класса Circle;
3) Система запрашивает у объекта класса Circle маршрут – как добраться до этого объекта начиная от объекта Stage. В данном случае маршрут будет выглядеть так
Обход маршрута события
Обход маршрута события состоит из двух фаз:
1) Захват события (capturing phase);
2) Всплытие события (Bubbling phase).
Событие обходит каждый узел маршрута дважды: один раз в течение фазы захвата события (сверху вниз) и один раз в течение фазы всплытия события (снизу вверх).
Вы можете зарегистрировать в узле фильтр события (event filter) и обработчик события (event handler), и обрабатывать события определенного типа, когда они будут проходить по маршруту в фазе захвата и в фазе всплытия соответственно.
То есть, зарегистрированный фильтр события позволяет обработать события на этапе захвата, когда событие передается по цепочке сверху вниз. Обработчик события позволяет обработать событие на этапе всплытия события, когда объект события передается обратно по той же цепочке снизу вверх.
Для регистрации фильтра события используется метод addEventFilter(), а для регистрации обработчика события – метод addEventHandler(). Сами объекты фильтра и обработчика ничем друг от друга не отличаются и оба реализуют интерфейс EventHandler<T extends Event>.
При вызове фильтра\обработчика события, тот объект, у которого был вызван обработчик, называется источником события (event source). То есть, при прохождении по маршруту источник события постоянно меняется.
Посмотрите еще раз на схему маршрута события, которая была приведена выше. Вы можете, например, зарегистрировать обработчик или фильтр в узле Group и обрабатывать все события определенного типа, которые будут проходить через объект Group. В данном случае, это будут все события, которые будут происходить в сцене, т.к. Group в данном случае – корневой узел. Рассмотрим пример
Мы зарегистрировали фильтр событий в узле HBox, и наш узел HBox будет выполнять метод handle() всякий раз, когда объект события, проходя по маршруту, будет проходить через объект HBox сверху вниз, на фазе захвата событий.
Также объект класса Event содержит методы consume() и isConsumed(). Вызов метода consume() означает, что вы указываете событию, что оно «поглощается» текущим обработчиком, и дальнейшая обработка не требуется – событие не идет дальше по цепочке диспетчеров событий. Метод isConsumed() возвращает true, если был вызван метод consume() и false иначе. Модифицируем наш пример следующим образом – напишем обработчик для кнопки 1
Как мы видим, при нажатии на кнопку отрабатывает фильтр в HBox и обработчик в b1. Теперь вернемся в фильтр в HBox и допишем следующее
Мы в фильтре вызвали у события метод consume() и после вызова этого метода, путешествие события по узлам заканчивается – событие «поглощается» и дальше по цепочке не передается.
Как мы видим, фон кнопки не поменялся, т.к. обработчик, который мы добавили в кнопку, не сработал, т.к. после вызова метода consume(), событие не было передано дальше по цепочке. Если вы зарегистрировали в одном узле несколько обработчиков/фильтров и в одном из них вызвали метод consume(), тогда система даст отработать другим обработчикам/фильтрам в этом узле, после чего обработка события останавливается.
Таким образом, расставляя в нужных местах фильтры, обработчики и используя метод consume() мы можем очень тонко настраивать реакцию приложения на различные события. Рассмотрим несколько примеров.
Пример 1. Представим себе, что мы хотим, чтобы при нажатии мыши на любом месте в нашем окне появлялось окно с информацией. Но у нас в окне есть некоторый элемент (например, красный прямоугольник), нажатие на который обрабатывается иначе.
Мы можем поступить следующим образом:
1) зарегистрировать обработчик на объекте красного прямоугольника. Внутри обработчика прописать нужное поведение, после чего вызвать метод consume();
2) для объекта сцена зарегистрировать обработчик, который показывает окно с информацией.
Таким образом, если целью события будет красный прямоугольник – выполнится обработчик для красного прямоугольника и событие будет поглощено. Во всех остальных случаях будет выполнен обработчик для объекта сцены.
Пример 2. Рассмотрим приложение «Калькулятор», которое мы рассматривали на предыдущей лабораторной работе. Попробуем реализовать функционал для нажатия клавиш цифровой клавиатуры и кнопки «C» (обнулить значение в поле ввода).
Создавать отдельный обработчик для каждой кнопки было бы нецелесообразно, поэтому мы можем реализовать требуемое поведение следующим образом.
Так как у нас кнопки расположены внутри менеджера компоновки GridPane, то мы можем создать фильтр событий, который будет обрабатывать событие типа ActionEvent.ACTION. Внутри фильтра мы выясним – было ли нажатие на цифровой блок или на какую-то другую кнопку. Если произошло нажатие на цифровой блок – тогда мы соответствующим образом обработаем нажатие, после чего поглотим событие.
Для реализации кнопки «С» создадим для нее отдельный обработчик события.
1) Отнаследуемся от класса MyButton и создадим класс отдельно для клавиш цифрового блока.
Кнопка цифрового блока будет хранить внутри поле value, которое будет содержать соответствующую цифру цифрового блока.
Далее, пропишем фильтр событий для GridPane
Таким образом, обработку кнопок цифрового блока берет на себя GridPane.
Для кнопки «C» реализуем отдельный обработчик
В итоге получаем нужный нам функционал
ЗАДАНИЕ НА ЛАБОРАТОРНУЮ РАБОТУ:
Представим себе класс Cart, который хранит набор положенных в корзину товаров и класс Order, в задачу которого входит формирование заказа из помещенных в корзину товаров. Для упрощения примера, представим себе, что "корзина" реализована обычным массивом и можем содержать не более 5 элементов.
Давайте внимательно посмотрим на метод makeOrder(). Он получает на вход объект Cart, после чего он должен взять каждое наименование и цену товара и, допустим, добавить его в базу данных. Как это сделать?
Самый очевидный способ реализовать перебор товаров - создадим геттер для поля items в классе Cart, после чего в методе makeOrder() с помощью цикла обойдем все элементы массива.
Подобное решение задачи является неправильным с точки зрения ООП:
нарушается принцип инкапсуляции, так как вы показываете внутреннюю структуру объекта. Сторонний класс может случайно или специально структуру и тогда объектом нельзя будет пользоваться;
класс Order теперь зависит от внутренней структуры класса Cart и, если мы каким-то образом изменим внутреннюю структуру класса (например, мы захотим вместо массива использовать какую-то коллекцию), или с помощью наследования модифицируем существующий класс - нам необходимо будет переписывать метод makeOrder();
мы устанавливаем сильную связь между логически разными классами, которые выполняют разные функции в приложении. Это нарушает принцип модульности.
Таким образом, нам необходимо решить следующую задачу: нам необходимо обойти все элементы "корзины" и добавить их в базу данных, но мы не должны показать внутреннюю структуру объекта типа Cart, класс Order должен работать успешно при любых изменениях внутри класса Cart.
Для решения этой задачи можно воспользоваться часто применяемым и очень простым паттерном проектирования, который называется "Итератор" (от относитс к категории паттернов поведения).
Краткое описание паттерна можно сформулировать так: итератор - это объект, который предоставляет интерфейс последовательного доступа к содержимому составных объектов, не раскрывая другим классам его внутренней структуры.
Давайте напишем свою версию этого паттерна. Прежде всего, нам необходимо описать интерфейс "Итератор", который будет описывать методы итератора.
Так как наш итератор может работать с разными элементами внутри составных объектов, то сделаем его обобщенным. Метод next() при каждом вызове возвращает следующий элемент составного объекта. То есть, если мы последовательно будем вызывать метод next(), то он будет возвращать первый, второй, третий и так далее элементы составного объекта.
Метод hasNext() возвращает нам сведения о том - есть ли еще эломенты, которые мы не обошли с помощью метода next(). Если впереди еще есть элементы - метод вернет true, если элементы закончились - метод вернет false.
Итак, мы описали интерфейс для итератора, теперь необходимо создать класс, который будет реализовывать интерфейс итератора.
Для этого необходимо создать так называемый внутренний класс.
В классе Cart создадим метод getIterator(), который будет создавать объект внутреннего класса CartIterator и возвращать его.
Теперь вернемся к классу Order. В методе makeOrder() получим итератор и проитерируем товары в корзине.
В результате:
класс Order не имеет доступа внутренней структуре объекта типа Cart;
класс Cart может спокойно изменить свою структуру - достаточно лишь переписать методы внутреннего класса CartIterator, чтобы реализовать методы итератора с учетом изменений структуры класса Cart;
класс Order будет продолжать нормальную работу, если внутренняя структура класса Cart изменится - главное, чтобы были корректно реализованы методы итератора;
вы можете предусмотреть несколько реализаций итератора и метод getIterator() может возвращать ту или иную реализацию интерфейса (например, вы можете предусмотреть несколько итераторов для обхода бинарного дерева - каждый итератор будет реализовывать свой способ обхода).
В реальности нам нет нужды свой интерфейс итератора - правильно будет воспользоваться стандартным интрефейсом java.util.Iterator и java.lang.Iterable. Это "стандартные" интерфейсы итератора, который все используют и в 99.9% случаях вы тоже должны его использовать.
Переделаем наш код для использования стандартных интерфейсов. Для начала перепишем внутренний класс CartIterator, чтобы он реализовывал интерфейс java.util.Iterator. Он мало чем отличается от нашего "самодельного" интерфейса Итератора. Минимально необходимо реализовать методы next()
и hasNext()
. Для полной реализации интерфейса необходимо дополнительно реализовать методы remove()
и forEachRemaining()
.
Далее следует указать, что наш класс Cart может предоставить итератор для последовательного доступа к элементам составного объекта. Для этого укажем, что наш класс реализует интерфейс Iterable<Item>. Для минимальной реализации интерфейса Iterable необходимо реализовать метод Iterator<T> iterator(), который возвращает объект класса, реализующего интерфейс java.util.Iterator.
Таким образом, мы указали, что наш класс Cart может вернуть объект итератора для последовательного доступа к элементам внутренней структуры. Реализация интерфейса Iterable<T> часто бывает полезна: некоторые методы в стандартных библиотеках Java принимают на входы объекты интерфейсного типа Iterable<T>, кроме того, вы теперь можете обойти элементы внутри Cart с помощью цикла конструкции for-each:
Поддержка итераторов - необъемлемая часть работы с коллекциями. В интерфейсе Collection определен метод iterator(), который возвращает объект итератора. Таким образом, все коллекции, которые реализуют интерфейс Collection, имеют реализованный класс итератора.
Работа с итератором коллекции ничем не отличается от использования итератора в нашем примере:
запросить у Collection итератор посредством метода iterator(). Полученный итератор готов вернуть начальный элемент последовательности;
получить следующий элемент последовательности вызовом метода next();
проверить, есть ли еще объекты в последовательности (метод hasNext());
удалить из последовательности последний элемент, возвращаемый итератором, методом remove().
Також, коллекции, реализующие интерфейс List, имеют еще один итератор, который определен в интерфейсе java.util.ListIterator. Интерфейс ListIterator отличается от Iterator тем, что имеет дополнительные методы, а также позволяет двигаться по коллекции в обоих направлениях (Iterator позволяет двигаться только вперед).
Подробнее про ListIterator смотрите здесь
В версии Java 8 появился еще один тип итератора - Spliterator. Он позволяет разбивать коллекцию на части, что позволяет обрабатывать коллекции параллельно в несколько потоков. На данном этапе концепция сплитератора может показаться вам непонятной, поэтому оставим его за скобками.
Подробнее можете прочитать здесь.
Классы TreeSet и TreeMap сохраняют элементы в отсортированном порядке. Однако понятие "порядок сортировки" точно определяют применяемый ими компаратор. По умолчанию эти классы сохраняют элементы, используя то, что в Java называется естественным упорядочением, то есть ожидаемым упорядочением, когда после a
следует b
, после 1 следует 2 и так далее.
Создадим класс Person и попробуем поместить объекты этого класса в коллекцию. Запустим код и с удивлением обнаружим, что произошла ошибка при выполнении программы.
Почему произошла ошибка и что означает? Текст ошибки говорит о том, что тип Person не может быть преобразован в тип Comparable.
Происходит следующее - чтобы мы могли использовать коллекции типа TreeSet или TreeMap или чтобы можно было сортировать элементы в коллекции - мы должны написать код для сравнения объектов, а он сейчас отсутствует и коллекция TreeSet не может поместить элемент в дерево.
Для того, чтобы объекты класса Person можно было сравнить и сортировать, класс должен реализовать интерфейс Comparable<E>.
Для реализации интерфейса нам необходимо переопределить метод compareTo(). На вход метода подается второй объект для сравнения. Первым объектом служит текущий объект, у которого этот метод вызывается.
Если первый (текущий) объект меньше, то метод должен возвращать отрицательное число, если объекты равны - возвращается 0, если текущий объект больше, метод возвращает положительное число. Перепишем метод, чтобы было яснее
Добавим в класс Person метод toString() и напишем небольшую демонстрационную программу
Обратите внимание, что коллекция TreeSet отсортировала значения по возрастанию согласно той логике, чтобы мы прописали в методе compareTo(). Такой способ задания порядка сортировки удобен и прост, но он имеет недостатки:
что делать, если необходимо во время работы программы изменить порядок упорядочения объектов?
мы не можем изменить порядок сортировки объектов стандартных классов вроде класса String (от этих классов нельзя наследоваться);
мы не можем изменить порядок сортировки уже кем-то написанных классов (наследоваться от существующего класса с целью переопределить метод compareTo() - не лучшая идея).
Для реализации компаратора, нам, в самом простом случае, необходимо переопределить метод compareTo(), который принимает на вход два объекта класса Person. Логика работы метода не отличается от метода compareTo() - если первый объект меньше - метод возвращает отрицательное число, если первый объект больше - метод возвращает положительное число, если объекты равны - метод возвращает 0.
В данном случае наш компаратор сравнивает объекты по полю lastName. Объект класса компаратора передается коллекции TreeSet при создании объекта коллекции. Напишем небольшую демонстрационную программу.
Мы видим, что элементы были упорядочены по полю lastName. Изменим коллекцию на List и отсортируем список с помощью компаратора
Можно заметить, что в первом случае порядок элементов в списке соответствует порядку добавления этих элементов. После сортировки порядок элементов был изменен -мы отсортировали элементы в списке по полю lastName. Вместо отдельных классов, для реализации компаратора можно использовать соответствующие лямбда-выражения.
Главной особенностью потокового API является способность выполнять очень сложные операции поиска, фильтрации и преобразования и иного манипулирования данными. Используя потоковый API, можно, например, сформировать последовательность действий, подобных запросам базы данных на языке SQL. Кроме этого, работа потокового API выполняется параллельно, а, следовательно, повышается производительность, особенно при обработке крупных потоков данных.
Потоки данных позволяют выполнять вычисления на более высоком концептуальном уровне, чем коллекции. С помощью потока данных можно указать, что и как именно требуется сделать с данными, а планирование операций предоставить конкретной реализации.
В Stream API определен ряд потоковых интерфейсов, входящих в состав пакета java.util.stream. В основе их иерархии лежит интерфейс BaseStream.
Производными от интерфейса BaseStream являются несколько типов интерфейсов. Наиболее употребительными из них является интерфейс Stream.
Поток данных - канал передачи данных, последовательность объектов. В самом потоке данные не хранятся, а только перемещаются и, возможно, фильтруются, сортируются или обрабатываются иным образом в ходе этого процесса. Как правило, действие потока данных не видоизменяет их источник.
Рассмотрим пример. Допустим, что требуетя подсчитать все длинные слова в книге. Сначала считаем файл и выделим отдельные слова в коллекцию:
Теперь попробуем посчитать все слова, длина которых больше 8 символов:
При использовании потокового API, подсчет осуществляется следующим образом:
Достаточно заменить метод stream() на метод parallelStream(), чтобы организовать параллельное выполнение операций фильтрации и подсчета слов:
Потоки данных действуют по принципу "что делать", а не "как это сделать". В рассматриваемом примере мы описываем, что нужно сделать: получить длинные слова и подсчитать их. При этом, мы не указываем, в каком порядке или потоке исполнения это должно произойти.
Потоки данных имеют свои особенности:
поток данных не сохраняет свои элементы. Они могут сохраняться в основной коллекции или формироваться по требованию;
потоковые операции не изменяют их источник. Например, метод filter() не удаляет элементы из нового потока данных, а выдает новый поток, в котором они отсутствуют;
потоковые операции выполняются по требованию (так называемая "ленивая инициализация", lazy initialization). Это означает, что они не выполняются до тех пор, пока не потребуется результат.
Вернемся к примеру. Методы stream() и parallelStream() выдают поток данных для списка слов words. А метод filter() возвращает другой поток данных, содержащий только те слова, длина которых больше 8 символов. И наконец, метод count() сводит этот поток данных в конечный результат.
Такая последовательность операций весьма характерна для обращения с поткоками данных. Конвейер операций организуется в следующие три стадии:
создание потока данных (метод stream());
указание промежуточных операций (intermediate operations) для преобразования исходного потока данных в другие потоки, возможно, в несколько этапов (метод filter());
выполнение конечной операции (terminal operation) для получения результата (метод count());
Конечная операция потребляет поток данных и дает конечный результат, например, находит минимальное значение в потоке данных или выполняет некоторое действие, как это делает метод forEach(). Если поток данных потреблен, он не может быть использован повторно.
Промежуточная операция производит поток данных и служит для создания конвейера для выполнения последовательности действий.
Получить поток данных можно самыми разными способами. Вероятно, самый распространенный способ - получение потока данных из коллекции. Любую коллекцию можно преобразовать в поток данных методом stream() из интерфейса Collection.
Поток данных можно получить и из массива. Это можно сделать несколькими способами, например, с помощью класса Arrays и с помощью класса Stream.
Вы также можете сгенировать поток с помощью метода generate() или iterate(). Обратите внимание, что метод generate() возвращает бесконечный поток, поэтому его бывает полезно ограничить с помощью промежуточной операции limit()
Также следует помнить, что в библиотеках Java существует много методов, которые могут вернуть потоки данных. Например, можно получить поток строк из класса Pattern или поток строк из файла с помощью метода класса Files
В результате выполнения промежуточных операций осуществляется преобразование потока данных в другой поток, элементы которого являются производными от элементов исходного потока.
На примерах выше мы рассматривали несколько таких операций, например, метод filter(), в результате которого получается новый поток данных с элементами, удовлетворяющими определенному условию. Рассмотрим еще один пример с использованием операции filter():
Нередко значения в потоке данных требуются каким-то образом преобразовать. Для этой цели можно воспользоваться методом map(), передав ему функцию, которая и выполняет нужное преобразование. Например
При вызове метода map() передаваемая ему функция применяется к каждому элементу потока данных, в результате чего образуется новый поток данных с полученными результатами.
Следующая операция - операция limit() возвращает поток данных, оканчивающийся после n элементов или по завершению исходного потока данных, если тот короче. Данный метод особенно удобен для ограничения бесконечных потоков данных до определенной длины. Так, в следующей строке кода получается новый поток данных, состоящий из 100 произвольных целых чисел
Метод skip() выполняет противоположную операцию - отбрасывает первые n элементов
Статический метод concat() позволяет соединить два потока данных. Первый из этих потоков не должен быть бесконечным
Метод distinct() возвращает поток данных, в котором исключены дубликаты.
Для сортирвоки потоков данных имеется несколько вариантов метода sorted(). Один из них служит для обработки потоков данных, состоящих из элементов типа Comparable, а другой принимает в качестве параметра компаратор типа Comparator
Как и во всех остальных потоковых преобразованиях, метод sorted() выдает новый поток данных, элементы которого происходят из исходного потока и располагаются в отсортированном порядке. Метод sorted() удобно применять в том случае, если процесс сортировки является частью поточного конвейера.
Метод peek() выдает другой поток данных с теми же элементами, что и у исходного потока, но при добавлении эелмента в новый поток вызывается лямбда-выражение, указанное в качестве аргумента метода peek().
Отличие метода peek() от метода forEach() является то, что метод метод peek() является промежуточной операцией и используется в целях отладки, а операций forEach() являетя конечной операцией, которая запускает конвейер и используется для получения результата работы потока.
Конечные операции часто называют методами сведения. Они выполняют конечные операции, сводя поток данных к непотоковому значению, которое может быть далее использовано в программе. С некоторыми из них вы уже знакомы: count()
, forEach()
и collect()
.
Метод count(), как уже было сказано, возвращает количество элементов в результирующем потоке. Приведем еще один пример
К числу других простых методов сведения относятся методы max() и min(), возвращающие наибольшее и наименьшее значения соответственно.
Обратите внимание, что эти методы возвращают значение типа Optional<T>, которое заключает в себе ответ на запрос или обозначают, что запрашиваемые данные отсутствуют, поскольку поток оказался пустым. Раньше в подобных случаях возвращалось пустое значение null. Но это могло привести к исключениям в связи с пустыми указателями в не полностью протестированной системе. Тип Optional удобнее для обозначения остутствующего возвращаемого значения.
Метод findFirst() возвращает первое значение из непустой коллекции. Зачастую он применяется вместе с методом filter().
Если же потребуется любое совпадение, а не только первое, то следует воспользоваться методом findAny(). Это оказывается эффективным при распараллеливании потока данных, поскольку поток может известить о любом обнаруженном в нем совпадении, вместо того чтобы ограничиваться только первым совпадением.
Если же потребуется только выяснить, имеется ли вообще совпадение, то следует воспользоваться методом anyMatch()
Имеются также методы allMatch() и noneMatch(), возвращающие логическое значение true, если с предикатом совпадают все элементы в потоке данных или не совпадает ни один из его элементов соответственно. Эти методы также выгодно выполнять в параллельном режиме.
По завершении обработки потока данных нередко требуется просмотреть полученные результаты. С этой целью можно использовать метод forEach(), чтобы применить функцию к каждому элементу.
В параллельном потоке данных метод forEach() выполняет обход элементов в произвольном порядке. Если же требуется обработать данные в потоковом порядке, то следует вызвать метод forEachOrdered(), что может снизить производительность вычислений.
Но чаще всего требуется накапливать результаты в структуре данных (то есть, создать массив или коллекцию, которая будет содержать элементы итогового потока).
Если нужно получить результирующий массив, то можно воспользоваться методом toArray(). Создать обобщенный массив выполнения невозможно, и поэтому в результате вызова stream.toArray() возвращается массив типа Object[]. Если же требуется массив нужного типа, то этому методу следует передать конструктор такого массива.
Для накопления элементов потока данных с другой целью имеется метод collect, принимающий экземпляр класса, реализующего интерфейс Collector. В частности, класс Collectors предоставляет немало фабричных методов для наиболее полезных коллекторов. Для накопления потока данных в списке или множестве достаточно сделать один из следующих вызовов
Если же требуется конкретная разновидность получаемого множества, то нужно сделать следующий вызов:
Для накопления символьных строк, сцепляя их, используется следующие вызовы
Если результаты обработки потока данных требуется свести к сумме, среднему, максимуму или минимуму, можно воспользоваться методами типа summarizing()
Для накопления результатов в словарях используется метод Collectors.toMap() принимающий в качестве двух своих аргументов функции, чтобы получить ключи и значения их отображения. Процесс накопления данных в словарях имеет много нюансов, в рамках данного курса рассматриваются только простые случаи.
В Java имеется несколько способов хранения объектов (или, точнее, ссылок на объекты). Самый простой вариант хранения объектов - массивы. Массивы обеспечивают самый эффективный способ хранения групп объектов. Однако массив имеет фиксированный размер, а, в общем случае, во время написания программы разработчик не знает точное количество объектов. К тому же массивы неэффективны при большой объеме элементов. Для таких случаев, в Java и в других языках реализован механизм коллекций объектов.
Коллекция - это хранилище, поддерживающее различные способы накопления и упорядочения объектов с целью обеспечения возможностей эффективного доступа к ним. Они представляют собой реализацию абстрактных типов (структур) данных, поддерживающих три основные операции:
добавление нового элемента в коллекцию;
удаление элемента из коллекции;
изменение элемента в коллекции.
Коллекции в Java объединены в библиотеке java.util
и представляют собой контейнеры для хранения и манипулирования объектами. Основные интерфейсы коллекций:
Map<K, V>
- карата отображения вида "ключ-значение" (такие структуры называют словарями или ассоциативными массивами);
Collection<E>
- вершине иерархии, базовый интерфейс для коллекций;
List<E>
- специализирует коллекции для обработки списков;
Set<E>
- специализирует коллекции для обработки множеств, содержащих уникальные элементы.
Ниже представлены основные интерфейсы и классы коллекций.
Этот интерфейс служит основанием, на котором построен весь каркас коллекций, поскольку он должен быть реализован почти всеми классами коллекций (кроме коллекций, реализующих интерфейс Map
). Интерфейс Collection
является обобщенным, ссылочная переменная типа Collection объявляется следующим образом
Интерфейс Collection
расширяет интерфейс Iterable
. Это означает, что все коллекции можно перебирать, организовав цикл foreach
. В интерфейсе Collection
определяются основные методы, которые должны иметь все коллекции. Перечислим некоторые основные методы
Объекты вводятся в коллекции методом add()
. Следует, однако, иметь ввиду, что метод add()
принимает аргумент типа E
. Следовательно, добавляемые в коллекцию объекты должны быть совместимы с предполагаемым типом данных в коллекции. Вызвав метод addAll()
, можно ввести все содержимое одной коллекции в другую.
Вызвав метод remove()
, можно удалить из коллекции отдельный объект. Чтобы из коллекции удалить группу объектов, достаточно вызвать метод removeAll()
. А для того чтобы удалить из коллекции все элементы, кроме указанных, следует вызвать метод retainAll()
. Вызвав метод removeIf()
, можно удалить из коллекции элемент, если он удовлетворяет условию, которое задается в качестве параметра predicate. И наконец, для полной очистки коллекции достаточно вызвать метод clear()
.
Имеется также возможность определить, содержит ли коллекция определенный объект, вызвав метод contains()
. Чтобы определить, содержит ли одна коллекция все члены другой, следует вызвать метод containsAll()
. А определить, пуста ли коллекция, можно с помощью метода isEmpty()
. Количество элементов, содержащихся в данный момент в коллекции, возвращает метод size()
.
Оба метода toArray()
возвращают массив, который содержит элементы, хранящиеся в коллекции. Первый из них возвращает массив класса Object
, а второй - массив элементов того же типа, что и массив, указанный в качестве параметра этого метода. Обычно второй метод более предпочтителен, поскольку он возвращает массив элементов нужного типа. Эти методы оказываются важнее, чем может показаться не первый взгляд. Ведь обрабатывать содержимое коллекции, используя синтаксис массивов, иногда оказывается очень выгодно. Обеспечив связь коллекции с массивом, можно извлечь выгоду из обоих языковых средств Java.
Две коллекции можно сравнить на равенство, вызвав метод equals()
. Точный смысл равенства может зависеть от конкретной коллекции. Например, метод equals()
можно реализовать таким способом, чтобы он сравнивал значения элементов, хранимых в коллекции. В качестве альтернативы методу equals()
можно сравнивать ссылки на эти элементы.
Еще один очень важный метод iterator()
возвращает итератор, а метод spliterator()
- итератор разделитесь для коллекции. Итераторы очень часто используются для обращения с коллекциями. И наконец, методы stream()
и parallelStream()
возвращают поток данных типа Stream
, использующий коллекцию для своих элементов.
Список (List) представляет собой динамический массив - упорядоченный набор элементов и может содержать повторяющиеся элементы. Вы можете получить доступ к любому элементу по индексу. Список является одним из наиболее используемых типов коллекций.
Основные свойства коллекций, реализующих интерфейс List
:
список может включать одинаковые элементы;
элементы в списке хранятся в том порядке, в котором они помещались;
можно получить доступ к любому элементу по его порядковому номеру (индексу) внутри списка.
Начиная с версии Java 9, в интерфейс List
внедрен фабричный метод of()
, у которого имеется целый ряд перегруженных вариантов, возвращающих неизменяемую коллекцию на основе значений, составленную из переданных аргументов.
Особое назначение фабричного метода of()
- предоставить удобный, эффективный способ для создания небольшой коллекции типа List
. Во всех перегружаемых вариантах данного метода не допускается указывать пустые (null) элементы создаваемого списка. И в любом случае конкретная реализация интерфейса List
не указывается.
Существуют два основных класса, реализующих List
.
Класс ArrayList
с превосходной скоростью произвольного доступа к элементам, но относительно медленными операциями вставки и удаления элементов в середине. Пожалуй, самая часто используемая коллекция. ArrayList
инкапсулирует в себе обычный массив, длина которого автоматически увеличивается при добавлении новых элементов.
Так как ArrayList
использует массив, то время доступа к элементу по индексу выполняется за константное время (в отличие от класса LinkedList
). При удалении произвольного элемента из списка, все элементы находящиеся "правее", смещаются на одну ячейку влево, при этом реальный размер массива (его емкость, capacity) не изменяется. Если при добавлении элемента массив будет полностью заполнен, будет создан новый массив размером n * 3 / 2 + 1
, в него будут помещены все элементы из старого массива плюс новый, добавляемый элемент.
Несмотря на то, что емкость объектов типа ArrayList
наращивается автоматически, ее можно увеличивать и вручную. вызывая метод ensureCapacity()
. Это может потребоваться в том случае, если заранее известно, что в коллекции предполагается сохранить намного больше элементов, чем она содержит в данный момент. Увеличив емкость списочного массива в самом начале его обработки, можно избежать дорогостоящей операции постепенного наращивания списка.
С другой стороны, если потребуется уменьшить размер базового массива, на основе которого строится объект типа ArrayList
до текущего количества хранящихся объектов, следует вызвать метод trimToSize()
.
Связанный список LinkedList
представляет собой коллекцию с оптимальным последовательным доступом и низкозатратными операциями вставки и удаления в середине списка. Операция произвольного доступа LinkedList
выполняет относительно медленно, но обладает более широкой функциональностью, чем ArrayList
.
LinkedList
состоит из узлов, каждый из которых содержит как собственно данные, так и две ссылки ("связки") на следующий и предыдущий узел списка. Доступ к произвольному элементу осуществляется за линейное время (но доступ к первому и последнему элементу списка всегда осуществляется за константное время - ссылки постоянно хранятся на первый и последний элементы, так что добавление элемента в конец списка вовсе не значит, что придется перебирать весь список в поисках последнего элемента).
В целом, в абсолютных величинах LinkedList
проигрывает ArrayList
и по потребляемой памяти и по скорости выполнения операций.
В общем случае, следует использовать ArrayList
. Коллекцию LinkedList
имеет смысл использовать в случае, если происходит интенсивная вставка\удаление в середину списка либо необходимо гарантированное, заранее известное время добавления элемента в список.
Рассмотрим основные методы интерфейса List
Рассмотрим работу основных методов, заявленных в интерфейсе List
Обратите внимание, что класс ArrayList
преобразуется в List
посредством восходящего преобразования. В идеале, большая часть кода должна взаимодействовать с этими интерфейсами (хотя это не всегда возможно), а точный тип указывается только в точке создания контейнера.
Интерфейс Queue (очередь) расширяет интерфейс Collection
и определяет поведение очереди, которая действует как список по принципу "первым вошел - первый вышел". Иначе говоря, объекты помещаются в один "конец" очереди, а извлекаются из другого "конца". Таким образом, порядок занесения объектов в контейнер будет совпадать с порядком его извлечения оттуда. Очереди также играют важную роль в параллельном программировании, потому что они обеспечивают безопасную передачу объектов между задачами.
Существуют разные виды очередей, порядок организации в которых основывается на некотором критерии. Интерфейс Queue
является обобщенным, ссылочная переменная типа Queue
объявляется следующим образом
Перечислим основные методы, определенные в интерфейсе Queue
Класс LinkedList
, который мы рассматривали в связи с интерфейсом List
, также реализует интерфейс Deque
и поддерживает поведение очередей. Восходящее преобразование LinkedList
в Queue
позволяет использовать объект как очередь.
Класс PriorityQueue
расширяет класс AbstractQueue
и реализует интерфейс Queue
. Он служит для сздания очереди по приоритетам на основании компаратора очереди.
В приоритетной очереди следующим извлекается элемент, обладающий наивысшим приоритетом.
Например, в аэропорту клиент может быть обслужен вне очереди, если его самолет готовится к вылету. В системе передачи сообщений некоторые сообщения могут содержать более важную информацию; они должны быть срочно обработаны независимо от времени поступления.
Если при построении очереди компаратор не указан, то применяется компаратор, выбираемый по умолчанию для того типа данных, который сохраняется в очереди. Таким образом, в начале (голове) очереди окажется элемент с наименьшим значением.
С помощью компаратора можно задать другую схему сортировки элементов в очереди. Например, когда в очереди сохраняются элементы, содержащие метку времени, для этой очереди можно задать приоритеты таким образом, чтобы самые давние элементы располагались в начале очереди.
Класс PriorityQueue
имеет одну особенность. Так как очередь с приоритетами реализована с помощью кучи (бинарное дерево с определенными свойствами), то попытка вывести элементы с помощью цикла foreach
, скорее всего, приведет к некорректному выводу элементов.
В интерфейсе Set определяется множество. Он расширяет интерфейс Collection и определяет поведение коллекций, не допускающих дублирования элементов. Таким образом, метод add() возвращает false при попытке ввести в множество дублирующий элемент. В этом интерфейсе не определяется никаких дополнительных методов.
Интерфейс Set объявляется следующим образом
Интерфейс Set не добавляет никакой функциональности по сравнению с интерфейсом Collection, поэтому в Set нет дополнительный функциональности. Вместо этого Set представляет собой разновидность Collection.
Хеш-таблица хранит информацию, используя так называемый механизм хеширования, в котором содержимое ключа используется для определения уникального значения, называемого хеш-кодом. Хеш-код затем применяется в качестве индекса, с которым ассоциируются данные, доступные по этому ключу. Преобразование ключа в хеш-код выполняется автоматически - вы никогда не узнаете самого хеш-кода. Также ваш код не может напрямую индексировать хеш-таблицу.
Выгода от хеширования состоит в том, что оно обеспечивает константное время выполнения методов add(), contains(), remove() и size(), даже для больших объемов данных.
Если вы хотите использовать HashSet для хранения объектов собственных пользовательских классов, то вы ДОЛЖНЫ переопределить методы hashCode() и equals(), иначае два логически-одинаковых объекта будут считаться разными, так как при добавлении элемента в коллекцию будет вызываться метод hashCode() класса Object (который вернет разный хеш-код для двух логически одинаковых объектов).
Важно отметить, что класс HashSet не гарантирует упорядоченности элементов, поскольку процесс хеширования сам по себе обычно не порождает сортированных наборов. Если вам нужны сортированные наборы, то лучшим выбором может быть класс TreeSet.
TreeSet - коллекция, которая хранит свои элементы в виде упорядоченного дерева. TreeSet использует сбалансированное красно-черное дерево для хранения элементов.
Для опреаций add(), remove(), contains() потребуется гарантированное время log(n).
Интерфейс Map описывает коллекцию, состоящую из пар "ключ-значение". У каждого ключа только одно значение, что соответствует математическому понятию однозначной функции или отображения. Такую коллекцию часто называют словарем (dictionary) или ассоциативным массивом (associative array). Несмотря на то, что интерфейс Map входит в список коллекций Java, он не расширяет интерфейс Collection.
Интерфейс Map соотносит уникальные ключи со значениями. Ключ - это объект, который вы используете для последующего извлечения данных. Задавая ключ и значение, вы можете помещать значения в объект Map. После того как это значение сохранено, вы можете получить его по ключу. Интерфейс Map - это обобщенный интерфейс, объявленный так, как показано ниже:
Здесь в качестве K указывается тип ключа, а в качестве V - тип значения.
Рассмотрим основные методы, объявленные в интерфейсе Map
Обращение с отображениями опирается на две основные операции, выполняемые методами get() и put(). Чтобы ввести значение в отображение, следует вызвать put(), указав ключ и значение, а для того чтобы получить значение из отображения - вызвать метод get(), передав ему ключ в качестве аргумента. По этому ключу будет возвращено связанное с ним значение.
Как упоминалось ранее, Map не реализует интерфейс Collection, хотя является частью каркаса коллекций. Тем не менее, можно получить представление отображения в виде коллекции. Для этого можно воспользоваться методом entrySet(), возвращающим множество, содержащее элементы отображения (для получения множества ключей можно использовать метод keySet(), для получения множества значений можно использовать метод values()).
Этот интерфейс позволяет обращаться с отдельными записями в отображении. Напомним, что метод entrySet(), объявляемый в интерфейсе Map, возвращает множество типа Set, содержащее записи из отображения. Каждый элемент этого множества представляет собой объект типа Map.Entry. Интерфейс Map.Entry является обобщенным и объявляется следующим образом:
где K обозначает тип ключей, а V - тип хранимых в отображении значений.
Этот класс расширяет класс AbstractMap и реализует интерфейс Map. В нем используется хеш-таблица для хранения отображений, и благодаря этому обеспечивается постоянное время выполнения методов get() и put(), даже в случае отображения с большим количеством элементов. Класс HashMap является обобщенным и объявляется приведенным ниже образом, где K обозначает тип ключей, а V - тип хранимых в отображении значений.
Класс TreeMap расширяет класс AbstractMap и реализует интерфейс NavigableMap. В нем создается отображение, размещаемое в древовидной структуре. В классе TreeMap предоставляются эффективные средства для хранения пар "ключ-значение" в отсортированном порядке и обеспечивается их быстрое извлечение. Следует заметить, что в отличие от HashMap, древовидное отображение гарантирует, что его элементы будут отсортированы по порядку возрастания ключей. Класс TreeMap является обобщенным и объявляется следующим образом
где K обозначает тип ключей, а V - тип хранимых в отображении значений.
Классы WeakHashMap, LinkedHashMap и интерфейс NavigableMap выносятся на самостоятельное изучение.
В фреймворке коллекций определяется ряд алгоритмов, которые можно применять к коллекциям и отображениям. Эти алгоритмы определены в виде статических методов класса Collections
. По сути, класс Collections предоставляет огромный набор методов для различных операций с коллекциями.
Полный список методов класса Collections можно найти здесь.
Приведем список наиболее полезных методов класса Collections
В рамках данного курсе мы не будем подробно рассматривать методы этого класса, важно запомнить одно правило - если вы хотите произвести какие-то манипуляции с коллекцией - сначала попробуйте найти нужный функционал в классе Collections
. Скорее всего, он там будет присутствовать в виде готового метода.
Также, в библиотеке java.util присутствует класс Arrays. Класс Arrays предоставляет различные удобные методы для работы с массивами. Эти методы помогает восполнить функциональный пробел между коллекциями и массивами. Например, метод asList() возвращает список, исходя из указанного массива, а метод binarySearch() использует алгоритм двоичного поиска для обнаружения заданного значения.
Полный список методов класса Arrays можно найти здесь.
Приведем список наиболее полезных методов класса Arrays
Применительно к классу Arrays действует то же правило - если вы хотите произвести какие-то манипуляции с массивом - сначала попробуйте найти нужный функционал в классе Arrays
. Скорее всего, он там будет присутствовать в виде готового метода.
Ниже приведена таблица, которая позволит вам облегчить процесс выбора нужной коллекции.
Для корректной работы JavaFX рекомендуется установить 8, 9 или 10 версию JDK.
Для работы JavaFX на с более поздними версиями Java требуется специальным образом создавать новый проект. Подробнее читайте по этой ссылке.
Отличие программы с графическим интерфейсом от консольной программы заключается не столько в способе подачи информации (использование графических объектов против текстового ввода и вывода информации), сколько в порядке выполнения (control flow) программного кода.
Консольная программа начинает свою работу с точки входа (метод main()
), выполняет все инструкции, указанные в этом методе (включая создание объектов классов и вызовы методы этих объектов), после чего завершает свою работу. Пользователь может вводить значения в консоль, но в строго определенный программистом момент и программа не будет работать до тех пор, пока пользователь не осуществит ввод.
Такой порядок работы программы удобен, если рассматривать программу как математическую функцию или как решение какой-то математической задачи. Программа выполняет набор действий, а после того, как все действия выполнены, программы завершает свою работу.
Программа, использующая графический интерфейс пользователя (graphic user interface или GUI), работает иначе. Работа GUI-программы основана на событиях (event-driven; разработку программы с графическим интерфейсом иногда называют event-driver programming).
В данном случае событие - это изменение состояния объекта, которое может быть зафиксировано и обработано программой. Если говорить проще, событие - это:
взаимодействие пользователя с компонентами графического интерфейса;
выполнение некоторыми объектами определенного условия (например, сработал таймер, была выполнена некоторая операция);
сообщения от операционной система (например, прерывания операционной системы, сбой аппаратного или программного обеспечения и так далее).
Пока что будем считать, что события - это результат взаимодействия пользователя с компонентами графического интерфейса. Пользователь нажал на кнопку, передвинул курсор мыши, ввел символ из клавиатуры, выбрал пункт списка, осуществил скроллинг страницы - все эти действия порождают события (events).
Среда Java (JVM) содержит механизмы, которые позволяют приложению реагировать на события - выполнять некоторые действия в ответ на нажатие кнопки, движение курсора, ввода символа из клавиатуры и так далее.
Каким образом приложение реагирует на события? Реакция на событие – это выполнение некоторого кода. Так как исполняемый код в ОО-языках хранится в методах, то реакция на событие, по сути, сводится к вызову определенного метода, который что-то делает. Таким образом, когда пользователь, например, нажимает на кнопку, то программа реагирует путем выполнения некоторого кода.
В Java существует огромное количество различных типов событий – даже самое незначительное действие может служить источником одного, а иногда и целой серии событий.
Важно понять, что разработчик не обязан создавать методы для реакции на все возможные события, которые теоретически могут произойти в программе. Программист должен определить, на какие события программа будет реагировать, и какая будет реакция, а какие события программа будет игнорировать
Схему работы GUI-приложения можно представить следующей схемой. Это очень упрощенная схема, но она дает понимание того, как работает GUI-приложение.
Вы можете обратить внимание на то, что вышеприведенная схема работы GUI-приложения напоминает цикл. На самом деле, это так и есть. Работу приложения можно, очень условно, разделить на три этапа:
Подготовительный этап. На этом этапе программа создает необходимые объекты графического интерфейса (но пока их не показывает), устанавливает связи между объектами интерфейса и выполняет другую подготовительную работу. На этом этапе приложение не обрабатывает события и не взаимодействует с пользователем;
Основной этап работы приложения. Приложение входит в бесконечный цикл (он называется event loop), в ходе которого, приложение на каждой итерации цикла проверяет – произошли ли какие-то события. Если есть произошедшие события – оно отправляет первое в очереди событие на обработку, на следующей итерации – следующее за ним в очереди событие, и так далее. Если нет произошедших событий – приложение просто пропускает эту итерацию;
Завершение работы приложения. Приложение «крутится» в event loop до тех пор, пока не будет подана команда на закрытие приложения (пользователь выбрал пункт «Выйти из приложения», закрыл приложение из диспетчера задач, операционная система принудительно «убила» приложение и так далее). При поступлении команды на закрытие, приложение выходит из бесконечного цикла, после чего, оно может выполнить некоторые заключительные действия, после чего закрывается.
Так как, согласно парадигме Java, всё является объектом, то и графический интерфейс, по сути, является набором объектов различных классов (кнопка, поле ввода, окно приложения – всё это является объектами соответствующих классов), который объединены в библиотеки графического интерфейса.
В Java нет никаких ограничений на реализацию своей библиотеки графического интерфейса – вы можете написать свою библиотеку, которая будет реализовывать свои классы компонентов графического интерфейса. Таким образом, наряду с библиотеками, которые были предложены разработчиками языка Java (AWT, Swing, JavaFX), существует множество библиотек, которые были разработаны сторонними фирмами или просто отдельными сообществами программистов (SWT, Apache Pivot и другие). Рассмотрим библиотеки, которые были предложены разработчиками языка Java.
AWT (Abstract Window Toolkit) – первая предложенная разработчиками библиотека GUI для Java. Главным недостатком данной библиотеки было то, что она использовала нативные компоненты ОС. Таким образом, внешний вид приложения, запущенного под ОС Window мог сильно отличаться от внешнего вида того же приложения, запущенного под OC Linux. На данный момент, эта библиотека считается устаревшей и не используется.
Swing – наиболее известная и вторая по счету библиотека GUI для Java. Обратите свое внимание на тот факт, что библиотека Swing активно использует компоненты AWT, наследуясь от них. Таким образом, хотя AWT и не используется напрямую, но Swing, во многом, опирается на библиотеку AWT.
Библиотека Swing является надежным проверенным средством для разработки GUI, а также имеет множество сторонних компонентов и классов, которые помогут вам в разработке. Недостатком Swing является то, что эта библиотека предназначена только для desktop-приложений и морально устарела, т.к. не поддерживает современные инструменты разработки графических интерфейсов (привязка данных, использование CSS и XML для описания интерфейса и так далее).
Разработчики языка Java, хотя и поддерживают библиотеку Swing, но настоятельно рекомендуют использовать библиотеку JavaFX.
Данная библиотека обладает всеми преимуществами Swing и, дополнительно предлагает более рациональный, простой в употреблении и усовершенствованный подход к построению GUI, а также значительно упрощает воспроизведение объектов благодаря автоматической перерисовке.
Стартовый класс JavaFX-приложения должен расширять класс Application, который входит в состав пакета javafx.application
Компоненты JavaFX содержится в отдельных пакетах, имена которых начинаются с префикса javafx
(например, javafx.application
, javafx.stage
, javafx.scene
и так далее).
Точкой входа для JavaFX-приложений является базовый класс Application
. Точка входа программиста вызывает метод класса Application, который запускает JavaFX-приложение.
В классе Application важными для нас являются три метода (они называются методами жизненного цикла, т.к. они вызываются системой в определенный момент «жизни» приложения):
метод init()
вызывается в момент, когда приложение только начинает выполняться. Он служит для выполнения различных инициализаций. Если инициализация не требуется – просто не переопределяйте данный метод;
метод start()
вызывается после init()
. Этот метод в классе Application
является абстрактным, поэтому нам необходимо его переопределить. Именно с него начинается работа приложения. Здесь создаются и настраиваются компоненты графического интерфейса;
метод stop()
вызывается, когда приложение завершается. Именно в нем должны быть произведены все операции очистки или закрытия. Если это не требуется – просто не переопределяем метод.
Когда запускается приложение, JavaFX выполняет следующие действия:
создает объект класса, который наследуется от класса Application
;
вызывает метод init()
;
вызывает метод start()
;
ждет завершения приложения. Приложение может завершиться в следующих случаях:
приложение вызвало метод Platform.exit()
;
закрыто последнее окно и атрибут implicitExit
класса Platform
равен true
;
вызывает метод stop()
.
Для того чтобы запустить JavaFX-приложение на выполнение, следует вызвать статический метод launch()
, определяемый в классе Application
.
Вызов метода launch()
приводит к построению приложения и последующему вызову методов init()
и start()
. Возврат из метода launch()
не происходит до тех пор, пока приложение не завершится.
При выборе терминов для JavaFX, разработчики пользовались театральной терминологией и представляли работу графического интерфейса как театр: в театре есть одна или несколько театральных сцен (окон). На театральной сцене, в течение спектакля (работы программы) сменяют друг друга сцены из спектакля (контейнер), на которых расположены различные декорации (элементы графического интерфейса).
Центральным понятием в JavaFX является Stage
(во многих книгах этот переводится как «подмостки», имеется ввиду «театральные подмостки»). Класс Stage
является нашей театральной сценой, подмостками, основным контейнером, который, как правило, представляет собой обрамлённое окно со стандартными кнопками: закрыть, свернуть, развернуть. Внутри Stage
содержится сцена – объект класса Scene
, которая может быть заменена другим объектом класса Scene
. Объект класса Stage
может содержать одновременно только один объект класса Scene
.
Объект класса Scene
содержит в один элемент – специальную вершину, которая называется корневая вершина (root node). Это самый верхний и единственный узел графа, не имеющий родителя. Все узлы в сцене прямо или косвенно происходят от корневого узла. В качестве корневого узла может выступать любой элемент графического интерфейса, который наследуется от абстрактного класса Parent
.
Корневой узел включает в себя множество различных графических компонент – кнопок, полей, переключателей, надписей – всё то, что нам нужно для создания графического интерфейса. На первом этапе думайте о Stage
просто как об окне приложения, а о Scene
– как о полотне, на котором размещаются нужные вам элементы. Все элементы графического интерфейса будут добавляться в корневую вершину.
В рамках одного окна вы можете менять объекты Scene
и, таким образом, менять содержимое окна. Вы также можете создать другие объекты Stage
– когда вам нужно открыть другие окна в приложении. Стартовый объект Stage
(аргумент primaryStage
) создает за вас JavaFX и передает вам как аргумент метода start()
.
Отдельные элементы сцены называются узлами. Например, кнопка, поле ввода, надпись и так далее. Некоторые узлы могут содержать в себе другие узлы (например, группа радиокнопок). Совокупность всех узлов сцены называется графом сцены, который образует дерево. Базовым классом для всех узлов служит класс Node
. От Node
, прямо или косвенно, происходят другие классы, например Parent
, Group
, Region
и Control
.
Если узел может иметь потомков, тогда он называется узлом ветвления (branch node), если у узла не может быть потомков, он называется листом (leaf node). Узлы ветвления наследуются от класса Parent
.
Рассмотрим небольшой пример. Построим графический интерфейс в виде панели с тремя надписями.
В качестве панели будет использоваться компонент HBox
. Этот компонент является менеджером компоновки (Layout Manager) – специальным контейнером, в который можно помещать элементы, которые будут расположены в определенном порядке.
Компонент HBox
размещает содержащиеся в нем элементы в виде горизонтального ряда. Такой компонент, очевидно, является узлом ветвления, так как он может иметь детей. Текстовая надпись (класс Text
), очевидно, является листом, т.к. вы не можете ничего поместить в текстовую надпись.
Граф сцены для вышеприведенного приложения будет следующим:
Код для такого приложения будет выглядеть следующим образом:
При разработке GUI можно часто столкнуться с проблемой: как сделать так, чтобы при изменении размера окна, расположенные в нем компоненты не смешивались в кучу, а вели себя определенным образом. Необходимо, чтобы элементы либо пропорционально уменьшались или увеличивались в размерах, либо оставались того же размера, появлялись полосы скроллинга, компоненты как-нибудь перемещались по экрану и так далее.
Для того, чтобы самостоятельно не заниматься отслеживанием таких ситуаций, во многих библиотеках графического интерфейса используются специальные компоненты, которые называются менеджерами компоновки (Layout Manager).
Менеджеры компоновки определяют размер и расположение компонентов, а так же, при изменении размера окна пропорционально масштабируют компоненты формы.
При разработке графического интерфейса, необходимо определить – какие менеджеры компоновки подойдут для реализации задуманного интерфейса, после чего расположить их в правильном порядке и добавить в них необходимые элементы.
BorderPane
Класс располагает свои дочерние узлы в одном из пяти регионов: top
, bottom
, left
, right
, center
. Эти регионы могут быть любых размеров. Если не поместить ни одного элемента в какой-либо регион, для него не будет выделено пространство.
Менеджер BorderPane
используется, когда необходимо достичь классического вида приложения – с панелью инструментов вверху, статусной строкой внизу, навигационной панелью слева, дополнительной информацией справа и рабочей областью в центре. Например:
Рассмотрим небольшой пример
HBox
Менеджер разметки HBox
позволяет легко упорядочить элементы в горизонтальный ряд. С помощью метода setPadding()
можно установить расстояние между узлами и краями HBox
. С помощью метода setSpacing()
можно регулировать расстояние между узлами. Менеджер поддерживает установку стиля.
VBox
Менеджер разметки VBox
работает схожим с HBox
образом, за исключением того, что узлы упорядочены в вертикальный ряд.
StackPane
Данный менеджер разметки размещает узлы в один стек, один на другой. Новый добавленный узел будет расположен поверх старого. Данный менеджер полезен, когда необходимо, например, расположить текст поверх фигуры и изображения или для создания комплексных фигур.
GridPane
Данный менеджер разметки позволяет создавать гибкую сеть из рядов и колонок, в которой располагаются узлы. Узел может быть помещен в любую ячейку сетки и объединять ячейки, если нужно. Этот менеджер полезен для создания форм или любой разметки, которая может быть организована рядами и колонками.
Обратите внимание на то, как в разметке GridPane
задаются свойства отдельных колонок и рядов: создается объект класса ColumnConstraints
и RowConstrains
соответственно, в объектах устанавливаются нужные свойства, после чего этот объект передается GridPane
(также, это можно сделать, указав индекс колонки или ряда).
FlowPane
FlowPane
– класс поточной компоновки. Элементы в этой компоновке располагаются построчно с автоматическим переходом на новую строку, если требуется.
Рассмотрим пример:
Попробуем изменять размер окна и увидим, что если кнопки не помещаются в окно по ширине, то кнопки автоматически переносятся на следующую строку.
По умолчанию, поточная компоновка выполняется по горизонтали, хотя можно указать и поточную компоновку по вертикали. Имеется возможность указать и другие свойства компоновки, в том числе промежутки между элементами по горизонтали и по вертикали, а также выравнивание.
TilePane
Работа этого менеджера компоновки схожа с работой FlowPane
, отличие в том, что дочерние узлы помещаются в сетку, ячейки которого имеют одинаковый размер.
Реализуем предыдущий пример, но сделаем кнопку 3 больше остальных. Обратите внимание на отличия между работой FlowPane
и TilePane
Обратите внимание, что TilePane
, исходя из размеров самого большого узла, высчитывает размер ячейки, и каждый элемент помещает в эти ячейки одинакового размера.
AnchorPane
Разметка AnchorPane
позволяет привязывать элементы к верхнему, нижнему, правому, левому краям контейнера. При изменении размеров окна, узлы поддерживают позицию относительно привязки. Узлы могут быть привязаны к нескольким позициям, и к одной позиции может быть привязано более одного узла
Group
Особняком в списке классов-контейнеров стоит класс Group
. Элементы в Group
не привязываются к местоположению, их позиция задается в абсолютных значениях. Если не задано другое, все дочерние узлы группы позиционируются по координате (0,0) (верхний левый угол).
Компонент Group
часто используют для того, чтобы применить различные эффекты и преобразования к целому набору компонентов, которые находятся в группе. Размеры компонента Group
определяются размерами его потомков.
Попробуем добавить пару кнопок в группу
На этом примере мы видим, что две кнопки наложены друг на друга, т.к. обе находятся в верхнем левом углу без всякой компоновки. Если мы попробуем изменить размеры окна, то увидим, что кнопки остаются на своем месте и разметка никак не реагирует на изменение размеров
Попробуем установить для кнопок позицию в группе. Мы видим, что кнопки расположились согласно координатам.
Полезным свойством Group
является возможность устанавливать эффекты и преобразований для всей группы. На примере ниже мы устанавливаем эффект BoxBlur
и этот эффект автоматически применяется ко всем узлам внутри группы.
За создание меню в JavaFX отвечают несколько классов:
MenuBar
– панель меню;
Menu
– раздел меню (Главная, Вставка и т.д.);
MenuItem
– действие в пункте меню;
RadioButtonItem
– для создания пункта меню в виде RadioButton
(выбор одного из нескольких вариантов);
CheckMenuItem
– создание пункта меню в виде CheckBox
(выделение\снятие выделения);
SeparatorMenuItem
- для отделения одной категории от другой.
В JavaFX нет ограничений на расположение панели меню, однако, как правило, панель меню располагается вверху окна. Панель меню добавляется как обычный узел, наряду с другими компонентами графического интерфейса. Панель меню содержит один или несколько разделов. Каждый раздел в панели выглядит как кнопка с текстовым значением. Рассмотрим небольшой пример
При нажатии на раздел меню ничего не происходит, т.к. мы добавили пункты меню. Давайте добавим разнообразные пункты меню в раздел «Файл» и посмотрим на результат
В качестве примера воспроизведем интерфейс приложения «Калькулятор» для Windows 7.
Для начала, нам необходимо понять – какие менеджеры компоновки нам следует использовать. При разработке GUI желательно использовать как можно меньшую вложенность менеджеров разметок и как можно меньший граф сцены.
Для начала, мы можем заметить, что калькулятор состоит из трех вертикальный областей: меню, поле вывода результата и кнопки калькулятора.
Следовательно, здесь уместным будет использование менеджера разметки VBox
, который размещает элементы в вертикальный список. Элемент класса VBox
будет нашим корневым узлом графа (root node).
Создадим метод configureMenu()
, в котором мы будем иницализировать и настраивать объекты меню.
Разделы меню мы заполнять не будем, а пока что попробуем воспроизвести окно результата. Создадим метод configureResultView()
, в котором мы будем создавать и настраивать объекты для реализации окна показа результата.
Текстовое поле (класс Text
) само по себе имеет не очень много возможностей для настройки выравнивания и внешнего вида. Поэтому, чтобы добиться сходства с оригинальным калькулятором, поместим текстовое поле в HBox
, а уже HBox
поместим как второй дочерний узел для VBox
.
Теперь перейдем к третьему элементу VBox
, в котором будут содержаться кнопки. Для того, чтобы выбрать нужную разметку, давайте внимательно посмотрим на область с кнопками.
Нетрудно догадаться, что это разметка типа Grid – то есть, сетка с набором кнопок. Две кнопки («=» и «0» занимают две строчки и два столбца соответственно). Таким образом, используем менеджер GridPane
. Создадим метод configureButtons()
, в котором будем создавать кнопки и помещать их в соответствующие ячейки сетки.
Для начала необходимо настроить кнопки. По умолчанию, размер кнопки определяется ее содержимым. Нам же необходимо будет, чтобы кнопка могла растягиваться в ширину и высоту, чтобы растягиваться в нашей сетке. Лучшим вариантом будет – создать собственный класс, который наследуется от стандартной кнопки и в конструкторе собственного класса вызвать метод setMaxSize()
Теперь наша кнопка будет растягиваться в случае необходимости. Теперь реализуем метод. Безусловно, добавление можно было бы реализовать более компактно, однако, в качестве примера, реализуем добавление каждой кнопки отдельно.
Далее, создадим объект класса ColumnConstraints
, после чего передадим объект этого классов для каждой колонки нашей таблицы. В этом объекте мы указываем, что колонка должна максимально расширяться. Также, установить отступы и зазоры между ячейками таблицы.
Полный текст класса представлен в конце лекции
Исключение (exception) - это ненормальная ситуация (термин "исключение" здесь следует понимать как "исключительная ситуация"), возникающая во время выполнения программного кода. Иными словами, исключение - это ошибка, возникающая во время выполнения программы (в runtime).
Исключение - это способ системы Java (в частности, JVM - виртуальной машины Java) сообщить вашей программе, что в коде произошла ошибка. К примеру, это может быть деление на ноль, попытка обратиться к массиву по несуществующему индексу, очень распространенная ошибка нулевого указателя (NullPointerException
) - когда вы обращаетесь к ссылочной переменной, у которой значение равно null и так далее.
В любом случае, с формальной точки зрения, Java не может продолжать выполнение программы.
Обработка исключений (exception handling) - название объектно-ориентированной техники, которая пытается разрешить эти ошибки.
Программа в Java может сгенерировать различные исключения, например:
программа может пытаться прочитать файл из диска, но файл не существует;
программа может попытаться записать файл на диск, но диск заполнен или не отформатирован;
программа может попросить пользователя ввести данные, но пользователь ввел данные неверного типа;
программа может попытаться осуществить деление на ноль;
программа может попытаться обратиться к массиву по несуществующему индексу.
Используя подсистему обработки исключений Java, можно управлять реакцией программы на появление ошибок во время выполнения. Средства обработки исключений в том или ином виде имеются практически во всех современных языках программирования. В Java подобные инструменты отличаются большей гибкостью, понятнее и удобнее в применении по сравнению с большинством других языков программирования.
Преимущество обработки исключений заключается в том, что она предусматривает автоматическую реакцию на многие ошибки, избавляя от необходимости писать вручную соответствующий код.
В Java все исключения представлены отдельными классами. Все классы исключений являются потомками класса Throwable
. Так, если в программе возникнет исключительная ситуация, будет сгенерирован объект класса, соответствующего определенному типу исключения. У класса Throwable
имеются два непосредственных подкласса: Exception
и Error
.
Исключения типа Error
относятся к ошибкам, возникающим в виртуальной машине Java, а не в прикладной программе. Контролировать такие исключения невозможно, поэтому реакция на них в приложении, как правило, не предусматривается. В связи с этим исключения данного типа не будут рассматриваться в книге.
Ошибки, связанные с работой программы, представлены отдельными подклассами, производными от класса Exception
. В частности, к этой категории относятся ошибки деления на нуль, выхода за пределы массива и обращения к файлам. Подобные ошибки следует обрабатывать в самой программе. Важным подклассом, производным от Exception
, является класс RuntimeException
, который служит для представления различных видов ошибок, часто возникающих во время выполнения программ.
Каждой исключительной ситуации поставлен в соответствие некоторый класс. Если подходящего класса не существует, то он может быть создан разработчиком.
Так как в Java ВСЁ ЯВЛЯЕТСЯ ОБЪЕКТОМ, то исключение тоже является объектом некоторого класса, который описывает исключительную ситуацию, возникающую в определенной части программного кода.
«Обработка исключений» работает следующим образом:
когда возникает исключительная ситуация, JVM генерирует (говорят, что JVM ВЫБРАСЫВАЕТ исключение, для описания этого процесса используется ключевое слово throw
) объект исключения и передает его в метод, в котором произошло исключение;
вы можете перехватить исключение (используется ключевое слово catch
), чтобы его каким-то образом обработать. Для этого, необходимо определить специальный блок кода, который называется обработчиком исключений, этот блок будет выполнен при возникновении исключения, код должен содержать реакцию на исключительную ситуацию;
таким образом, если возникнет ошибка, все необходимые действия по ее обработке выполнит обработчик исключений.
Если вы не предусмотрите обработчик исключений, то исключение будет перехвачено стандартным обработчиком Java. Стандартный обработчик прекратит выполнение программы и выведет сообщение об ошибке.
Рассмотрим пример исключения и реакцию стандартного обработчика Java.
Мы видим, что стандартный обработчик вывел в консоль сообщение об ошибке. Давайте разберемся с содержимым этого сообщения:
Строка
сообщает нам тип исключения, а именно класс ArithmeticException
(про классы исключений мы будем говорить позже), после чего сообщает, какая именно ошибка произошла. В нашем случае это деление на ноль.
Далее сообщается
в каком классе, методе и строке произошло исключение. Используя эту информацию, мы можем найти ту строчку кода, которая привела к исключительной ситуации, и предпринять какие-то действия. Строки
называются «трассировкой стека» (stack tracing). О каком стеке идет речь? Речь идет о стеке вызовов (call stack). Соответственно, эти строки означают последовательность вызванных методов, начиная от метода, в котором произошло исключение, заканчивая самым первым вызванным методом.
Для вызова методов в программе используется инструкция «call». Когда вы вызываете метод в программе, важно сохранить адрес следующей инструкции, чтобы, когда вызванный метод отработал, программа продолжила работу со следующей инструкции. Этот адрес нужно где-то хранить в памяти. Также перед вызовом необходимо сохранить аргументы функции, которые тоже необходимо где-то хранить.
Вся эта информация хранится в специальной структуре – стеке вызовов. Каждая запись в стеке вызовов называется кадром или фреймом (stack frame).
Подробнее: https://goo.gl/TpuYbS, https://goo.gl/S50lR4, https://goo.gl/CcyKCC, https://goo.gl/0EMyvu
Таким образом, зная, какая строка привела к возникновению исключения, вы можете изменить код либо предусмотреть обработчик событий.
Классы исключений
Как уже было сказано выше, исключение это объект некоторого класса. В Java существует разветвленная иерархия классов исключений.
В Java, класс исключения служит для описания типа исключения. Например, класс NullPointerException
описывает исключение нулевого указателя, а FileNotFoundException
означает исключение, когда файл, с которым пытается работать приложение, не найден. Рассмотрим иерархию классов исключений:
На самом верхнем уровне расположен класс Throwable
, который является базовым для всех исключений (как мы помним, JVM «выбрасывает» исключение», поэтому класс Throwable
означает – то, что может «выбросить» JVM).
От класса Throwable
наследуются классы Error
и Exception
. Среди подклассов Exception
отдельно выделен класс RuntimeException
, который играет важную роль в иерархии исключений.
В Java существует некоторая неопределенность насчет того – существует ли два или три вида исключений.
Если делить исключения на два вида, то это:
контролируемые исключения (checked exceptions) – подклассы класса Exception
, кроме подкласса RuntimeException
и его производных;
неконтролируемые исключения (unchecked exceptions) – класс Error
с подклассами, а также класс RuntimeException
и его производные;
В некоторых источниках класс Error
и его подклассы выделяют в отдельный вид исключений - ошибки (errors).
Класс Error
Далее мы видим класс Error
. Классы этой ветки составляют вид исключений, который можно обозначить как «ошибки» (errors). Ошибки представляют собой серьезные проблемы, которые не следует пытаться обработать в собственной программе, поскольку они связаны с проблемами уровня JVM.
На самом деле, вы конечно можете предпринять некоторые действия при возникновении ошибок, например, вывести сообщение для пользователя в удобном формате, выслать трассировку стека себе на почту, чтобы понять – что вообще произошло.
Но, по факту, вы ничего не можете предпринять в вашей программе, чтобы эту ошибку исправить, и ваша программа, как правило, при возникновении такой ошибки дальше работать не может.
В качестве примеров «ошибок» можно привести: переполнение стека вызова (класс StackOverflowError
); нехватка памяти в куче (класс OutOfMemoryError
), вследствие чего JVM не может выделить память под новый объект и сборщик мусора не помогает; ошибка виртуальной машины, вследствие которой она не может работать дальше (класс VirtualMachineError
) и так далее.
Несмотря на то, что в нашей программе мы никак не можем помочь этой проблеме, и приложение не может работать дальше (ну как может работать приложение, если стек вызовов переполнен или JVM не может дальше выполнять код?!); знание природы этих ошибок поможет вам предпринять некоторые действия, чтобы избежать этих ошибок в дальнейшем. Например, ошибки типа StackOverflowError
и OutOfMemoryError
могут быть следствием вашего некорректного кода.
Например, попробуем спровоцировать ошибку StackOverflowError
Получим такое сообщение об ошибке
Ошибка OutOfMemoryError
может быть вызвана тем, что ваш код, вследствие ошибки при программировании, создает очень большое количество массивных объектов, которые очень быстро заполняют кучу и свободного места не остается.
Ошибка VirtualMachineError
может означать, что следует переустановить библиотеки Java.
В любом случае, следует относиться к типу Error
не как к неизбежному злу и «воле богов», а просто как к сигналу к тому, что в вашем приложении что-то не так, или что-то не так с программным или аппаратным обеспечением, которое вы используете.
Справочник по исключениям типа Error смотрите здесь – https://goo.gl/oh7RdG
Класс Exception
Класс Exception
описывает исключения, связанные непосредственно с работой программы. Такого рода исключения «решаемы» и их грамотная обработка позволит программе работать дальше в нормальном режиме.
В классе Exception описаны исключения двух видов: контролируемые исключения (checked exceptions) и неконтролируемые исключения (unchecked exceptions).
Неконтролируемые исключения содержатся в подклассе RuntimeException
и его наследниках. Контролируемые исключения содержатся в остальных подклассах Exception
.
В чем разница между контролируемыми и неконтролируемыми исключениями, мы узнаем позже, а теперь рассмотрим вопрос – а как же именно нам обрабатывать исключения?
Обработка исключений
Обработка исключений в методе может выполняться двумя способами:
с помощью связки try-catch
;
с помощью ключевого слова throws
в сигнатуре метода.
Рассмотрим оба метода поподробнее:
Способ 1. Связка try-catch
Этот способ кратко можно описать следующим образом.
Код, который теоретически может вызвать исключение, записывается в блоке try{}
. Сразу за блоком try
идет блок код catch{}
, в котором содержится код, который будет выполнен в случае генерации исключения. В блоке finally{}
содержится код, который будет выполнен в любом случае – произошло ли исключение или нет.
Теперь разберемся с этим способом более подробно. Рассмотрим следующий пример – программу, которая складывает два числа, введенные пользователем из консоли
Первое, что нам нужно определить – и что является главным при работе с исключениями, КАКАЯ ИНСТРУКЦИЯ МОЖЕТ ПРИВЕСТИ К ВОЗНИКНОВЕНИЮ ИСКЛЮЧЕНИЯ?
То есть, мы должны понять – где потенциально у нас может возникнуть исключение? Понятно, что речь идет не об операции сложения и не об операции чтения данных из консоли. Потенциально опасными строчками кода здесь являются строчки
в которых происходит преобразование ввода пользователя в целое число (метод parseInt()
преобразует цифры в строке в число).
Почему здесь может возникнуть исключение? Потому что пользователь может ввести не число, а просто какой-то текст и тогда непонятно – что записывать в переменную a
или b
. И да, действительно, если пользователь введет некорректное значение, возникнет исключение в методе Integer.parseInt()
.
Итак, что мы можем сделать. «Опасный код» нужно поместить в блок try{}
Обратите внимание на синтаксис блока try
. В самом простом случае это просто ключевое слово try
, после которого идут парные фигурные скобки. Внутри этих скобок и заключается «опасный» код, который может вызвать исключение. Сразу после блока try должен идти блок catch()
.
Обратите внимание на синтаксис блока catch
. После ключевого слова, в скобках описывается аргумент с именем e
типа NumberFormatException
.
Когда произойдет исключение, то система Java прервет выполнение инструкций в блоке try
и передаст управление блоку catch
и запишет в этот аргумент объект исключения, который сгенерировала Java-машина.
То есть, как только в блоке try
возникнет исключение, то дальше инструкции в блоке try выполняться не будут! А сразу же начнут выполняться действия в блоке catch
.
Обработчик исключения находится в блоке catch
, в котором мы можем отреагировать на возникновение исключения. Также, в этом блоке нам будет доступен объект исключения, от которого мы можем получить дополнительные сведения об исключении.
Блок catch
сработает только в том случае, если указанный в скобках тип объекта исключения будет суперклассом или будет того же типа, что и объект исключения, который сгенерировала Java.
Например, если в нашем примере мы напишем код, который потенциально может выбросить исключение типа IOException
, но не изменим блок catch
тогда обработчик не будет вызван и исключение будет обработано стандартным обработчиком Java.
Способ 2. Использование ключевого слова throws
Второй способ позволяет передать обязанность обработки исключения тому методу, который вызывает данный метод (а тот, в свою очередь может передать эту обязанность выше и т.д.).
Изменим наш пример и выделим в отдельный метод код, который будет запрашивать у пользователя число и возвращать его как результат работы метода
Мы понимаем, что в данном методе может произойти исключение, но мы не хотим или не можем его обработать. Причины могут быть разными, например:
обработка исключений может происходить централизованно однотипным способом (например, показ окошка с сообщением и с определенным текстом);
это не входит в нашу компетенцию как программиста – обработкой исключений занимается другой программист;
мы пишем только некоторую часть программы и непонятно – как будет обрабатывать исключение другой программист, который потом будет использовать наш код (например, мы пишем просто какую-то библиотеку, которая производит вычисления, и как будет выглядеть обработка – это не наше дело).
В любом случае, мы знаем, что в этом коде может быть исключение, но мы не хотим его обрабатывать, а хотим просто предупредить другой метод, который будет вызывать наш код, что выполнение кода может привести к исключению. В этом случае, используется ключевое слово throws
, которое указывается в сигнатуре метода
Обратите внимание на расположение сигнатуру метода. Мы привыкли, что при объявлении метода сразу после скобок входных аргументов мы открываем фигурную скобку и записываем тело метода. Здесь же, после входных аргументов, мы пишем ключевое слово throws
и потом указываем тип исключения, которое может быть сгенерировано в нашем методе. Если метод может выбрасывать несколько типов исключений, они записываются через запятую
Тогда, в методе main мы должны написать примерно следующее
Основное преимущество этого подхода – мы передаем обязанность по обработке исключений другому, вышестоящему методу.
Если вы вызываете метод, который выбрасывает checked исключение, то вы ОБЯЗАНЫ предусмотреть обработку возможного исключения, то есть связку try-catch
.
Яркий пример checked исключения – класс IOException
и его подклассы.
Рассмотрим пример – попробуем прочитать файл и построчно вывести его содержимое на экран консоли:
Как мы видим, компилятор не хочет компилировать наш код. Чем же он недоволен? У нас в коде происходит вызов двух методов – статического метода Files.newBufferedReader()
и обычного метода BufferedReader.readLine()
.
Если посмотреть на сигнатуры этих методов то можно увидеть, что оба этих метода выбрасывают исключения типа IOException
. Этот тип исключения относится к checked-исключению и поэтому, если вы вызываете эти методы, компилятор ТРЕБУЕТ от вас предусмотреть блок catch
, либо в самом вашем методе указать throws IOException
и, таким образом, передать обязанность обрабатывать исключение другому методу, который будет вызывать ваш.
Таким образом, «оборачиваем» наш код в блок try
и пишем блок catch
.
Еще один способ - указать в сигнатуре метода, что он выбрасывает исключение типа IOException
и переложить обязанность обработать ошибку в вызывающем коде
Eсли метод выбрасывает checked-исключение, то проверка на наличие catch-блока происходит на этапе компиляции. И вы обязаны предусмотреть обработку исключения для checked-исключения.
Что касается unchecked-исключения, то обязательной обработки исключения нет – вы можете оставить подобные ситуации без обработки.
В большинстве языков существует всего лишь один тип исключений – unchecked. Некоторые языки, например, C#, в свое время отказались от checked-исключений.
Во-первых, мы не можем сделать все исключения checked, т.к. очень многие операции могут генерировать исключения, и если каждый такой участок кода «оборачивать» в блок try-catch
, то код получится слишком громоздким и нечитабельным.
С другой стороны, зачем нужно делать некоторые типы исключений checked? Почему просто не сделать все исключения unchecked и оставить решения об обработке исключений целиком на совести программиста?
В официальной документации написано, что unchecked-исключения – это те исключения, от которых программа «не может восстановиться», тогда как checked-исключения позволяют откатить некоторую операцию и повторить ее снова.
На самом деле, если вы посмотрите на различные типы unchecked-исключений, то вы увидите, что большинство их связаны с ошибками самого программиста. Выход за пределы массива, исключение нулевого указателя, деление на ноль – большинство из подобного рода исключений целиком лежат на совести программистов. Тогда мы можем сказать, что лучше программист пишет более хороший код, чем везде вставляет проверки на исключения.
Контролируемые исключения, как правило, представляют те ошибки, которые возникают не из-за программиста и предусмотреть которые программист не может. Например, это отсутствующие файлы, работа с сокетами, подключение к базе данных, сетевые соединения, некорректный пользовательский ввод.
Вы можете написать идеальный код, но потом вы отдадите приложение пользователю, а он введет название файла, которого нет или напишет неправильный IP для сокет-соединения. Таким образом, мы заранее должны быть готовыми к неверным действиям пользователя или к программным или аппаратным проблемам на его стороне и в обязательном порядке предусмотреть обработку возможных исключений.
Рассмотрим детально различные возможности механизма исключений, которые позволяют программисту максимально эффективно противодействовать исключениям:
Java позволяет вам для одного блока try
предусмотреть несколько блоков catch
, каждый из которых должен обрабатывать свой тип исключения
Важно помнить, что Java обрабатывает исключения последовательно. Java просматривает блок catch сверху вниз и выполняет первый подходящий блок, который может обработать данное исключение.
Так как вы можете указать как точный класс, так и суперкласс, то если первым блоком будет блок для суперкласса – выполнится он. Например, исключение FileNotFoundException
является подклассом IOException
. И поэтому если вы первым поставите блок с IOException
– он будет вызываться для всех подтипов исключений, в том числе и для FileNotFoundException
и блок c FileNotFoundException
никогда не выполнится.
Начиная с версии Java 7, вы можете использовать один блок catch
для обработки исключений нескольких, не связанных друг с другом типов. Приведем пример
Как мы видим, один блок catch используется для обработки и типа IOException
и NullPointerException
и NumberFormaException
.
Вы можете использовать вложенные блоки try
, которые могут помещаться в других блоках try
. После вложенного блока try
обязательно идет блок catch
Выбрасывание исключения с помощью ключевого слова throw
С помощью ключевого слова throw
вы можете преднамеренно «выбросить» определенный тип исключения.
Блок finally
Кроме блока try
и catch
существует специальный блок finally
. Его отличительная особенность – он гарантированно отработает, вне зависимости от того, будет выброшено исключение в блоке try
или нет. Как правило, блок finally
используется для того, чтобы выполнить некоторые "завершающие" операции, которые могли быть инициированы в блоке try
.
При любом развитии события в блоке try
, код в блоке finally
отработает в любом случае.
Блок finally
отработает, даже если в try-catch
присутствует оператор return
.
Как правило, блок finally
используется, когда мы в блоке try
работаем с ресурсами (файлы, базы данных, сокеты и т.д.), когда по окончании блока try-catch
мы освобождаем ресурсы. Например, допустим, в процессе работы программы возникло исключение, требующее ее преждевременного закрытия. Но в программе открыт файл или установлено сетевое соединение, а, следовательно, файл нужно закрыть, а соединение – разорвать. Для этого удобно использовать блок finally
.
Блок try-with-resources
Блок try-with-resources
является модификацией блока try
. Данный блок позволяет автоматически закрывать ресурс после окончания работы блока try
и является удобной альтернативой блоку finally
.
Внутри скобок блока try
объявляется один или несколько ресурсов, которые после отработки блока try-catch
будут автоматически освобождены. Для этого объект ресурса должен реализовывать интерфейс java.lang.AutoCloseable
.
Встроенные в Java исключения позволяют обрабатывать большинство распространенных ошибок. Тем не менее, вы можете создавать и обрабатывать собственные типы исключений. Для того, чтобы создать класс собственного исключения, достаточно определить как его произвольный от Exception
или от RuntimeException
(в зависимости от того, хотите ли вы использовать checked или unchecked – исключения).
Насчет создания рекомендуется придерживаться двух правил:
определитесь, исключения какого типа вы хотите использовать для собственных исключений (checked или unchecked) и старайтесь создавать исключения только этого типа;
старайтесь максимально использовать стандартные типы исключений и создавать свои типы только в том случае, если существующие типы исключений не отражают суть того исключения, которое вы хотите добавить.
Ниже представлены действия по обработке ошибок, которые характерны для плохого программиста. Ни в коем случае не рекомендуется их повторять!
Указание в блоке catch объекта исключения типа Exception. Существует очень большой соблазн при создании блока catch
указать тип исключения Exception
и, таким образом, перехватывать все исключения, которые относятся к этому классу (а это все исключения, кроме системных ошибок). Делать так крайне не рекомендуется, т.к. вместо того чтобы решать проблему с исключениями, мы фактически игнорируем ее и просто реализуем некоторую «заглушку», чтобы приложение продолжило работу дальше. Кроме того, каждый тип исключения должен быть обработан своим определенным образом.
Помещение в блок try
всего тела метода. Следующий плохой прием используется, когда программист не хочет разбираться с кодом, который вызывает исключение и просто, опять же, реализует «заглушку». Этот прием очень "хорошо" сочетается с первым приемом. В блок try
должен помещаться только тот код, который потенциально может вызвать исключение, а не всё подряд, т.к. лень обрабатывать исключения нормально.
Игнорирование исключения. Следующий плохой прием состоит в том, что мы просто игнорируем исключение и оставляем блок catch
пустым. Программа должна реагировать на исключения и должна информировать пользователя и разработчика о том, что что-то пошло не так. Безусловно, исключение это не повод тут же закрывать приложение, а попытаться повторить то действие, которое привело к исключению (например, повторно указать название файла, попытаться открыть базу данных через время и т.д.). В любом случае, когда приложение в ответ на ошибку никак не реагирует – не выдает сообщение, но и не делает того, чего от нее ожидали – это самый плохой вариант.
Метод
Описание
Object clone()
Создает новый объект, аналогичный клонируемому объекту
boolean equals(Object объект)
Определяет равнозначность объектов
void finalize()
Вызывается перед тем, как неиспользуемый объект будет удален "сборщиком мусора"
Class<?> getClass()
Определяет класс объекта во время выполнения
int hashCode()
Возвращает хеш-код, связанный с вызывающим объектом
void notify()
Возобновляет работу потока, ожидающего уведомления от вызывающего объекта
void notifyAll()
Возобновляет работу всех потоков, ожидающих уведомления от вызывающего объекта
String toString()
Возвращает символьную строку, описывающую объект
void wait()
void wait(long мсек)
void wait(long мсек, int наносек)
Ожидает исполнения другого потока
Метод
Описание
getSource()
Возвращает источник события типа Object
getTarget()
Возвращает цель события типа Object
getEventType()
Возвращает тип события типа EventType
Фокус – это некий указатель, который говорит о том, какой сейчас компонент активен и может реагировать на клавиатуру. В фокусе может находиться только один компонент. Фокус, как правило, отображается прямоугольником с тонкой линией или пунктирным прямоугольником. Фокус можно переключать, чтобы добраться до требуемого компонента. Как правило, переключение фокуса производится при помощи кнопки Tab.
Например, у нас есть несколько текстовых полей, в которые требуется ввести некие данные. Одновременно вводить данные в несколько полей мы не можем – значит должно быть что-то, что говорит, какой компонент сейчас активен и в него можно ввести данные с клавиатуры. Указатель, указывающий на поле, в которое мы в данный момент вводим данные, и есть фокус. Фокус могут иметь не только текстовые поля. Его могут иметь, например, и кнопки.
Метод
Описание
add(Object o)
Добавляет указанный объект в коллекцию
remove(Object o)
Удаляет указанный объект из коллекции
clear()
Удаляет все элементы из коллекции
size()
Возвращает количество элементов в коллекции
iterator()
Возвращает объект, который используется для доступа к элементам коллекции
Метод
Описание
void add(int index, E obj)
Добавляет в список объект obj
по индексу index
boolean addAll(int index, Collection<? extends E> col)
Добавляет в список все элементы коллекции col
по индексу index
. Если в результате добавления список был изменен, то возвращается true
, иначе возвращается false
E get(int index)
Возвращает из списка объект по индексу index
int indexOf(Object obj)
Возвращает индекс первого экземпляра заданного объекта в списке. Если заданный объект в списке отсутствует, возвращается значение -1
E remove(int index)
Удаляет объект по индексу index
из списка, при этом метод возвращает удаленный объект
E set(int index, E obj)
Присваивает элементу по индексу index
объект obj
(фактически, записывает новую ссылку на объект поверх старой), метод возвращает старый объект
List<E> sublist(int start, int end)
Возвращает список элементов, которые находятся между индексами start
и end
Метод
Описание
E element()
Возвращает элемент из головы очереди. Возращаемый элемент не удаляется. Если очередь пуста, генерируется исключение типа NoSuchElementException
boolean offer(E object)
Пытается ввести заданный object
в очередь. Возвращает логическое значение true, если object
введен, иначе - false
E peek()
Возвращает элемент из головый очереди. Если очередь пуста, возвращает пустое значение null
. Возвращаемый элемент не удаляется из очереди
E poll()
Возвращает элемент из головый очереди и удаляет его. Если очередь пуста, возвращает значение null
E remove()
Удаляет элемент из головы очереди, возвращая его. Генерирует исключение типа NoSuchElementException
, если очередь пуста