Содержание | Рекомендации по изучению | Задания

Занятие 1 | Занятие 2 | Занятие 3 | Занятие 4 | Занятие 6 | Занятие 7 | Занятие 8 | Занятие 9 | Занятие 10 | Занятие 11

Занятие 3

Основные понятия ООП (повторение)

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

Роль объектно-ориентированной методологии

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

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

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

Дело в том, что арсенал средств процедурного программирования — переменные и функции — оказывается недостаточно эффективным для написания программы, которая должна обрабатывать данные об объектах и процессах реального мира. Такая программа может быть написана, но сложность ее разработки превысит все мыслимые нормы. Для учета информации обо всех обрабатываемых параметрах множества объектов понадобится большое количество переменных. При этом очень легко забыть, какая переменная за что отвечает. Кроме того, эти переменные должны постоянно изменяться под воздействием различным функций, но при этом легко потерять контроль над тем, какая функция на какие переменные воздействует. В результате на определенном этапе программу становится почти невозможно понимать и отлаживать.

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

Объекты

В основе методологии объектно-ориентированного программирования (ООП) лежит понятие объекта. Объект — это некая сущность (например, предмет или процесс) с четко очерченными границами, имеющий смысл в контексте рассматриваемой предметной области. Например, студент Иванов, файл «info.txt», лекция по информационным технологиям — это все примеры объектов.

Каждый объект обладает набором свойств (или атрибутов). Например, студент Иванов имеет свойства Имя, Возраст, Цвет глаз, Оценка по матанализу. С каждым свойством связано текущее значение определенного типа. К примеру, возможные свойства объекта Студент Иванов: Имя = "Вася", Возраст = 19, Цвет глаз = СИНИЙ, Оценка по матанализу = 4.

Совокупность текущих значений свойств объекта обычно называют его состоянием.

Для каждого объекта существует определенный набор действий, которые с ним можно произвести. Например, объект файл «info.txt» можно открыть, прочитать, изменить, сохранить, удалить и т.д. Как правило, в результате выполнения действия изменяются значения некоторых свойств объекта. Совокупность всех действий, которые можно произвести над объектом, называется его поведением.

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

Классы

Объекты, обладающие одинаковым набором свойств и одинаковым поведением, относятся к одному классу. Класс — это абстракция, описание группы однородных объектов. Например, студент, лекция, файл, стул — все это примеры классов. Обратите внимание, имеется в виду не конкретный студент, например, Иванов, а студент вообще; этот класс представляет собой обобщенное понятие студента.

У класса есть имя, атрибуты и методы.

Имя выступает в качестве идентификатора класса, т.е. не может быть классов с одинаковыми именами. В качестве имени класса обычно выбирают существительное, описывающее абстрактный объект этого класса (Студент, Лекция, Файл, Стул — это все имена классов).

Атрибуты класса описывают свойства его объектов. С каждым атрибутом сопоставляется некоторый тип (это может быть обычный тип языка программирования вроде int, char, boolean, а может быть другой класс). Например, класс Зачетная книжка имеет атрибут Номер типа int (целое число) и атрибут Владелец типа Студент.

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

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

Приблизительный перечень классов получают, изучив предметную область программы и разграничив используемые в ней понятия. К этим классам добавляются те, которые необходимы для поддержки алгоритмов, которые будут использоваться в программе. Большая часть их берется в готовом виде из стандартных библиотек — например, класс Vector, реализующий одну из разновидностей списков в Java. Другие выделяются на основе устоявшихся шаблонов (так называемых паттернов проектирования) или личного опыта программиста.

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

Чтобы требуемые методы могли быть реализованы, в классе должны храниться некоторые данные. Например, чтобы студент мог сообщать свое имя, он должен его «знать», а чтобы операция Перевести на следующий курс применительно к студенту имела смысл, он должен «знать» свой текущий курс и при необходимости его увеличивать. Таким образом выделяется набор атрибутов.

В результате мы получаем объектно-ориентированную модель, которую можно использовать в программе.

Литература по теме:

1. Гради Буч. Объектно-ориентированный анализ и проектирование.

Объектно-ориентированное программирование в Java

Классы и объекты

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

Описание класса начинается с ключевого слова class, после которого указывается идентификатор — имя класса. Затем в фигурных скобках перечисляются атрибуты и методы класса. Атрибуты в языке Java называются полями (в дальнейшем мы будем использовать это наименование). Поля и методы называются членами класса.

Поля описываются как обычные переменные.

Правила записи методов рассматривались на предыдущем занятии.

Опишем для примера класс Dog (собака). У него будет два поля: кличка и возраст. При описании поведения собаки в этом простом примере ограничимся лаем. Конечно, лаять по-настоящему наша собака не будет (ведь это всего лишь программная конструкция), она будет выводить в консоль «гав-гав». Чтобы было интереснее, предположим, что все собаки, с которыми имеет дело наша программа, умны настолько, что когда их вынуждают лаять, они говорят «гав-гав» столько раз, сколько им лет.

Заметим, что в программе уже есть один класс (тот, в котором описан метод main()). Поскольку этот класс к собакам отношения не имеет, описывать новый класс Dog следует за его пределами.

class Dog { int age; // возраст String name; // кличка public void voice() { for (int i = 1; i <= age; i++) { System.out.println("гав-гав"); } } }

Самое главное — понять, что означает, когда некоторые переменные (поля) и функции (методы) собраны (описаны) в каком-то классе.

Класс должен описывать некоторое законченное понятие. Это может быть понятие из предметной области программы (собака, велосипед, аквариум, сессия) или понятие, необходимое для работы самой программы (очередь, список, строка, окно, кнопка, программа*).

Полями класса должны быть данные, относящиеся к этому понятию. Для собаки это возраст, кличка, порода и т.д., а для сессии — дата начала, продолжительность и т.д.

Методы класса, как правило, работают с данными этого класса. Например, метод voice() в нашем примере обращается к полю age (возраст).

Когда какой-то класс описан, могут создаваться объекты этого класса и с ними можно работать, вызывая их методы (кормить собаку, выгуливать, просить ее лаять — словом делать все то, что позволяет поведение класса, т.е. совокупность его методов).

Для обращения к объектам удобно использовать переменные, имеющие тип класса. Например, для работы с собаками опишем переменную типа Dog:

Dog x;

Переменная типа класса является ссылочной переменной, она не хранит данные (как переменные простых типов int, char и т.д.), а указывает на место в памяти, где эти данные хранятся (как переменные типа массива). Данными, на которые указывает только что описанная переменная x, может быть объект класса Dog. Его необходимо предварительно создать командой new:

x = new Dog();

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

x.voice();

Для того, чтобы обратиться к члену класса, необходимо указать его имя после имени объекта через точку.

Обратите внимание, «залаяла» именно та собака, на которую «указывала» переменная x. Если в программе были созданы другие собаки, они будут молчать до тех пор, пока не будет вызван их метод voice().

Таким образом, когда данные (поля) и команды (методы) описываются в одном классе, они оказываются тесно связаны друг с другом в объектах этого класса. Метод вызывается не сам по себе, а для конкретного объекта и работает с полями именно этого объекта.

Поэтому команды

voice(); age += 1;

не имеют никакого смысла, если употребляются вне методов класса Dog. Обязательно указание на конкретный объект, с которым производится действие. Внутри метода указание на конкретный объект вовсе не обязательно: в рассмотренном примере запись

for (int i = 1; i <= age; i++)
о ключевом слове this

означает, что для определения «продолжительности» лая будет проверяться возраст того самого объекта, для которого будет вызван данный метод. Этот объект обозначается ключевым словом this.

Метод voice() можно было описать и так:

public void voice() { for (int i = 1; i <= this.age; i++) { System.out.println("гав-гав"); } }

Ключевое слово this в этом примере наглядно указывает, что используется атрибут age именно того объекта класса Dog, для которого вызывается метод voice().

Когда имя метода или атрибута записывается без указания объекта, всегда подразумевается объект this.

Конструкторы классов

Конструктор — это особенный метод класса, который вызывается автоматически в момент создания объектов этого класса. Имя конструктора совпадает с именем класса.

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

public Dog(String n, int a) { name = n; age = a; }

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

Dog dog1 = new Dog("Тузик", 2);

В результате переменная dog1 будет указывать на «собаку» по кличке Тузик, имеющую возраст 2 года. Кстати, этот возраст можно узнать, заставив собаку лаять командой

dog1.voice();*

Конструкторы добавляются в класс, если в момент создания объекта нужно выполнить какие-то действия (начальную настройку) с его данными (полями). Сразу задать кличку и возраст собаки более естественно, чем каждый раз порождать безымянного щенка, а затем давать ему имя и быстро выращивать до нужного возраста (с учетом того, что программа скорее всего обрабатывает данные о собаках, которые на самом деле уже давно родились). Хотя до появления ООП программисты часто делали именно так.

Наследование

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

Наследование обычно возникает, когда все объекты одного класса одновременно являются объектами другого класса (отношение общее/частное). Например, все объекты класса Студент являются объектами класса Человек. В этом случае говорят, что класс Студент наследует от класса Человек. Аналогично класс Собака может наследовать от класса Животное, а класс Далматинец от класса Собака. Класс, который наследует, называется подклассом или потомком, а класс, от которого наследуют, называется суперклассом или предком.

Заметим, что если класс №2 является потомком класса №1, а класс №3 является потомком класса №2, то класс №3 является также потомком класса №1.

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

Для того, чтобы один класс был потомком другого, необходимо при его объявлении после имени класса указать ключевое слово extends и название суперкласса.

Например:

class Dalmatian extends Dog { // дополнительные поля и методы ... }
о классе Object

Если ключевое слово extends не указано, считается, что класс унаследован от универсального класса Object.

С класса Object начинается иерархия наследования. Любой другой класс является потомком класса Object и наследует от него три метода:

equals(Object obj) — позволяет сравнивать два объекта. Возвращает true, если объекты равны. Например, мы сравнивали строки (объекты класса String):

if (s1.equals(s2)) { ... }

toString() — «переводит» объект в строку (которую можно вывести в методе println()).

hashCode() — возвращает целое число, уникальное для всех объектов данного класса (не может быть двух объектов, для которых это число совпадает).

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

Модификаторы видимости

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

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

Ключевое слово protected означает, что доступ к полю или методу имеет сам класс и все его потомки.

Если при объявлении члена класса не указан ни один из перечисленных модификаторов, используется модификатор по умолчанию (default). Он означает, что доступ к члену класса имеют все классы, объявленные в том же пакете.

Перепишем класс Dog следующим образом:

class Dog { private int age;// возраст private String name; // кличка public Dog(String n, int a) { name = n; age = a; } public void voice() { for(int i = 1; i <= age; i++) { System.out.println("гав-гав"); } } }

Поля age и name окажутся скрытыми. Это значит, что мы не можем изменять их (или считывать их значение) где-либо за пределами класса*. Мы не сможем в методе main() создать объект класса Dog, а затем присвоить его полю age или name новое значение, как в следующем примере:

public static void main(String[] args) { Dog dog1 = new Dog("Тузик", 4); dog1.age = 10; // нельзя, поле age скрыто dog1.name = "Жучка"; // переименовать собаку тоже нельзя, поле name скрыто dog1.voice(); // это можно, метод voice() открытый }

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

об инкапсуляции

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

Например, в классе Dog есть целочисленное поле age (возраст). Можно оставить его открытым, тогда его при необходимости можно будет изменить простым присваиванием (очень удобно). Но при этом ничто не мешает присвоить этому полю заведомо некорректное значение (например, 666 или -5 или 3000). Это может произойти из-за ошибки в программе. Или, к примеру, пользователь вводит возраст собаки в текстовое поле, а программа присваивает его в ответ на нажатие кнопки (и пользователь может ошибиться). Это нежелательный случай. Лучше сделать поле age закрытым (private) и добавить два открытых метода: getAge() и setAge(). Первый метод будет просто возвращать значение скрытого поля:

public int getAge() { return age; }

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

public void setAge (int newAge) { if (newAge < 0) System.out.println("Как это понимать? Собака еще не родилась?"); else if (newAge > 30) System.out.println("Они столько не живут"); else age = newAge; }

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

Профессиональные программисты, разрабатывающие программы по объектно-ориентированной методологии, скрывают все поля своих классов, создавая для каждого из них открытые методы c приставками get и set, причем в методах set проводятся все необходимые проверки.

Заметим напоследок, что в нашем примере неправильный возраст может «прорваться» через конструктор при создании нового объекта. Никто ведь не мешает написать:

Dog dog1 = new Dog("Тузик", 2000);

Для того, чтобы этого не случилось, необходимо переписать конструктор:

public Dog(String n, int a) { name = n; age = setAge(a); }

Теперь проверка осуществляется в конструкторе. Попытка завести в программе собаку с явно некорректными данными не увенчается успехом. Если возраст будет меньше 0 или больше 30, присваивание не выполнится и атрибут age будет иметь значение по умолчанию (для типа int это 0). Такая вот маленькая собачка...

Упражнение

Наш класс Dog содержит принципиальную ошибку. Он имеет скрытое поле name, которому можно присвоить начальное значение во время создания объекта, но нельзя изменить и даже узнать впоследствии. Напишите методы getName() и setName(). Никаких проверок проводить не нужно.

Перегрузка

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

Например, в дополнение к конструктору, который уже есть в классе Dog, мы можем описать конструктор без параметров:

public Dog() { name = "Незнакомец"; }

В этом конструкторе объекту класса Dog (очевидно, собака с неизвестной кличкой) присваивается при регистрации в программе имя «Незнакомец». Теперь мы можем воспользоваться одним из двух конструкторов:

Dog dog1 = new Dog("Тузик", 2); // Собака по кличке Тузик, возраст 2 года Dog dog2 = new Dog(); // Собака по кличке «Незнакомец», возраст 0 Dog dog3 = new Dog(10); // Неверно! Не существует конструктора с такими параметрами

Нельзя создавать несколько одноименных методов с одинаковым числом и типом параметров.

Упражнение

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

Полиморфизм

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

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

Пусть, к примеру, мы хотим расширить наш класс Dog классом BigDog, для того, чтобы наша программа особым образом моделировала поведение больших злых собак. В частности, большие собаки лают по-другому. Во-первых, громче, а во-вторых, они не умеют считать. Поэтому мы переопределим метод voice():

class BigDog extends Dog { public void voice() { for (int i = 1; i <= 30; i++) { System.out.print("ГАВ-"); } } }

Теперь создадим в методе main() двух разных собак: обычную и большую и заставим их лаять.

Dog dog = new Dog("Тузик", 2); dog.voice(); BigDog bigdog = new BigDog(); bigdog.voice();

Объект подкласса всегда будет одновременно являться объектом любого из своих суперклассов. Поэтому в том же примере мы могли бы обойтись и одной переменной:

Dog dog = new Dog("Тузик", 2); dog.voice(); dog = new BigDog(); dog.voice();
о впечатляющих возможностях полиморфизма

Т.е. переменная dog имеет тип Dog, но в третьей строке она начинает указывать на объект класса BigDog, то есть БОЛЬШУЮ собаку, которая при вызове метода voice() будет лаять как БОЛЬШАЯ собака. Это одна из впечатляющих возможностей объектно-ориентированного программирования.

Приемы программирования: наследование и полиморфизм

Главное преимущество полиморфизма — это возможность работать с объектами разных классов, происходящих от одного общего предка так, как будто бы они относились к одному классу.

Рассмотрим типичный пример.

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

Время от времени программа должна выполнять над этими фигурами какие-то действия. Например, когда окно программы сворачивается, а потом снова разворачивается, надо заново нарисовать все эти фигуры. Когда пользователь щелкает по фигуре мышкой, ее надо выделить, а когда пользователь перетаскивает границы фигуры — изменить ее размеры.

Придерживаясь методологии объектно-ориентированного программирования, мы приходим к выводу, что каждая фигура должна рисовать себя «сама». То есть, команды для прорисовки круга выполняются в одном из методов класса Circle, например, в методе paint(). Действительно, все параметры фигуры должны храниться в полях ее класса, поэтому легко можно написать такой метод. Аналогично, фигура «сама» рисует себе выделение — для этого есть метод paintSelection() — и передвигается — метод move(int x, int y). Задача основной программы — просто обращаться к этим методам при необходимости.

Программа должна где-то хранить объекты, которые создаст пользователь. Поскольку заранее неизвестно, сколько будет этих объектов, необходимо воспользоваться какой-нибудь структурой для хранения множества объектов, например массивом. Но при создании массива требуется указать тип его элементов. А в нашей программе пользователь может создавать самые разные объекты. Так что придется завести несколько массивов: один для точек, один для кругов и так далее. Если понадобится заново нарисовать все объекты на экране, нужно будет перебрать все элементы в каждом из этих массивов:

for (int i = 0; i < points.length; i++) { points[i].paint(); } for (int i = 0; i < circles.length; i++) { circles[i].paint(); }

... и так далее, для каждого типа фигуры.

Более того, если пользователь щелкнул мышкой по экрану, чтобы выбрать фигуру, программа, получившая координаты мыши, должна найти фигуру, в которую попадают эти координаты. Предположим, каждая фигура сама может осуществить проверку с помощью метода checkPoint(int x, int y), который возвращает значение true, если точка с координатами x, y находится внутри этой фигуры. Но для того, чтобы вызвать этот метод, снова придется перебрать все массивы. И так для каждой операции, что очень неудобно.

Благодаря наследованию мы имеем две прекрасные возможности. Для того, чтобы ими воспользоваться, нам нужно создать класс Figure и описать в нем методы, общие для всех фигур: paint(), checkPoint(int x, int y) и так далее. Не обязательно программировать эти методы, мы все равно не будем обращаться к ним.* Важно, чтобы они были.

Первая возможность: мы можем присваивать объекты классов-потомков переменным любого из классов-предков.

Это вполне логично. Ведь если класс Кошка унаследован от класса Животное, то объект Мурзик является одновременно объектом класса Кошка и объектом класса Животное.

Следовательно, мы можем создать один большой массив* для хранения объектов класса Figure:

Figure[] figures = new Figure[100]; // создаем массив для хранения 100 фигур

Теперь мы можем помещать в этот массив любые фигуры:

figures[0] = new Point(30, 30); // добавили в массив точку с координатами 30, 30 figures[1] = new Circle(60, 20, 10); // добавили круг с координатами 60, 20 радиуса 10 figures[2] = new Rectangle(0, 0, 30, 40); // добавили прямоугольник ...

Вторая возможность. Мы можем обращаться к методам, объявленным в классе-предке, но вызываться будет перегруженный метод, в зависимости от того, к какому классу на самом деле относится объект, к которому мы обратились.

Мы можем нарисовать все фигуры, хранящиеся в нашем массиве:

for (int i = 0; i < figures.length; i++) { if (figures[i] != null) figures[i].paint(); }

В массиве хранятся элементы типа Figure. В этом классе есть метод paint(), поэтому мы вполне можем к нему обратиться. Но в самом классе Figure этот метод не делает ничего (ведь мы не могли разработать процедуру рисования, подходящую для всех без исключения фигур). Зато в классе Point, унаследованном от класса Figure, мы переопределили этот метод — написали его заново так, чтобы он рисовал точку (координаты точки хранятся в скрытых атрибутах класса Point). А в первом элементе массива figures[0] у нас хранится именно точка. Хотя мы обращаемся с ней как с просто фигурой, Java знает, что при вызове метода paint() нужно использовать именно тот вариант, который переопределен в классе Point. Аналогично команда figures[1].paint(); нарисует круг, а figures[2].paint(); нарисует прямоугольник.

Мы рассмотрели очень подробный пример, поскольку описанный прием является одним из наиболее часто используемых средств в арсенале объектно-ориентированного программирования.

Конструктор по умолчанию

Если в классе не описан ни один конструктор, для него автоматически создается конструктор по умолчанию. Этот конструктор не имеет параметров, все что он делает — это вызывает конструктор без параметров класса-предка.

Поэтому мы и смогли создать объект класса BigDog в примере с большой собакой, хотя не описывали в классе никаких конструкторов. Если вспомнить конструктор без параметров, который у нас есть в классе Dog, мы поймем, что переменная bigdog в предыдущем примере ссылалась на собаку по кличке "Незнакомец".

Вызов конструктора суперкласса

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

BigDog bigdog = new BigDog("Полкан", 8); // Ошибка. Такого конструктора в классе нет

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

BigDog (String n, int a) { super(n, a); }

Ключевое слово super означает суперкласс (в нашем случае это класс Dog). В примере мы вызываем с его помощью конструктор суперкласса. При этом мы передаем два параметра — строку и число, — так что из всех конструкторов будет выбран именно тот, который нас интересует.

Вызов конструктора суперкласса должен происходить в самом начале конструктора.

Вместо вызова конструктора суперкласса можно вызвать один из конструкторов того же самого класса. Это делается с помощью ключевого слова this() — с параметрами в скобках, если они нужны.

Если в начале конструктора нет ни вызова this(), ни вызова super(), автоматически происходит обращение к конструктору суперкласса без аргументов.

Приведение типов

Объект класса-потомка можно присвоить переменной типа класса-предка. При этом Java производит автоматическое преобразование типа, называемое расширением. Расширение — это переход от более конкретного типа к менее конкретному. Переход от byte к int — это тоже расширение.

Рассмотрим пример, имеющий отношение к основному заданию. В программе есть класс User, предназначенный для обработки информации о пользователях системы. В этом классе есть метод enter(String login, String password), который возвращает true, если переданные в метод логин и пароль совпадают с логином и паролем, скрытым в полях класса.

Мы наследуем от класса User два подкласса: Admin и, к примеру, Member (для программы координации участников встречи, см. задание 13). Класс Admin может понадобиться нам впоследствии для каких-то специфичных действий, связанных с управлением системой, а класс Member моделирует участников проекта, которые с помощью программы пытаются выбрать оптимальное место для встречи. Открытый метод addRequest(String place, int day, int from, int to) вызывается, когда участник проекта предлагает новый вариант времени и места встречи.

В главном классе программы мы храним массив* users, содержащий всех пользователей системы. Элементы этого массива имеют тип User, но мы можем присваивать им ссылки на объекты как класса Member, так и класса Admin. В этот момент и будет происходить расширение типа.

Member member = new Member(...); users[3] = member; // Java проводит автоматическое преобразование типа Member к типу User, чтобы поместить переменную member в массив users

Для того, чтобы найти пользователя с введенными логином и паролем программа выполняет запрос:

for (int i = 0; i < users.length; i++) { if (users[i].enter(log, passw)) currentUser = users[i]; }

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

Предположим, нам известно, что переменная currentUser сейчас ссылается на объект класса Member и текущий пользователь предлагает встретиться у фонтана в среду с 17 до 19 часов. Необходимо вызвать метод addRequest(), но у нас не получится сделать это командой

currentUser.addRequest("Фонтан", 3, 17, 19);

поскольку в классе User нет метода addRequest().

Однако мы можем осуществить явное преобразование переменной currentUser к типу Member. Такое преобразование (переход от менее конкретного типа к более конкретному) называется сужением. Явное преобразование делается с помощью оператора, представляющего собой имя целевого типа в скобках.

((Member)currentUser).addRequest("Фонтан", 3, 17, 19);

Здесь мы, прежде чем вызвать метод addRequest(), преобразовали переменную currentUser к типу Member. Нам было позволено сделать это, поскольку Member является потомком User. Однако, если бы во время выполнения программы оказалось, что на самом деле переменная currentUser не ссылалась на объект класса Member, в программе возникла бы ошибка.

Оператор instanceof

Чтобы уточнить, соответствует ли текущее значение переменной конкретному типу, используется оператор instanceof.

Проверим, не является ли текущий пользователь администратором (в этом случае программа должна перейти в режим управления):

if (currentUser instanceof Admin) {...}

Анонимные и вложенные классы

о вложенных классах

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

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

class Dog { ... class Eye { private boolean opened; public void close() { opened = false; System.out.println("глаз закрыт"); } public void open() { opened = true; System.out.println("глаз открыт"); } public boolean isOpened() { return opened; } } Eye rightEye = new Eye(), leftEye = new Eye(); }

Мы сразу же добавили в класс Dog два поля класса Eye и проинициализировали их вновь созданными объектами (это можно было сделать и в конструкторе). Теперь у собаки есть два глаза и она может открывать их и закрывать. Например, предположим, что все собаки лают с закрытым правым глазом. Тогда метод voice() надо переписать так:

public void voice() { rightEye.close(); for (int i = 1; i <= age; i++) { System.out.println("гав-гав"); } rightEye.open(); }

Обратиться ко вложенному классу нужно с помощью составного имени (в нашем случае это Dog.Eye). Если обращение происходит из содержащего его класса, имя можно сократить (как и было сделано выше).

Класс можно объявить внутри метода другого класса. В этом случае класс "виден" только внутри метода (за пределами метода нельзя объявить переменную типа этого класса).

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

Описание анонимного класса начинается с вызова конструктора его суперкласса, после чего в фигурных скобках описывается тело класса.

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

Пусть, например, в нашей программе собачьего питомника имеется массив dogs объектов типа Dog. И мы хотим добавить в этот массив совершенно уникальную собаку, которая не лает, а разговаривает. Необходимо описать класс, унаследованный от класса Dog, в котором будет соответствующим образом переопределен метод voice(). Но поскольку нам гарантированно понадобится только одна такая собака, мы можем описать анонимный класс прямо в месте добавления собаки в питомник (посадим ее в клетку № 10):

dogs[10] = new Dog(){ public void voice() { System.out.println("Я уникальная говорящая собака."); } };

И, несмотря на то, что мы не сможем создавать переменных этого класса, мы спокойно можем пользоваться новой собакой как объектом класса Dog. А благодаря полиморфизму вызов метода

dogs[10].voice();

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

Модификатор static

Любой член класса можно объявить статическим, указав перед его объявлением ключевое слово static. Статический член класса «разделяется» между всеми его объектами.

Для поля это означает, что любое изменение его значения, сделанное одним из объектов класса, сразу же «увидят» все остальные объекты.

Метод, объявленный с модификатором static, "дает обещание" не изменять никаких полей класса, кроме статических.

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

Упражнение

Модифицируйте класс Dog таким образом, чтобы можно было считать собак, созданных во время работы программы. Для этого введите статический атрибут count (изначально равный нулю) и увеличивайте его на единицу в каждом конструкторе. Создайте в методе main() несколько объектов класса Dog, а затем выполните для проверки команду:

System.out.println("Всего было создано собак: " + Dog.count);

Модификатор final

о модификаторе final

Любое поле класса можно объявить неизменяемым, указав перед его объявлением ключевое слово final. Неизменяемому полю можно присвоить значение только один раз (обычно это делается сразу при объявлении).

Константы в языке Java очевидным образом описываются путем совмещения модификаторов static и final. Например, мы можем объявить константу PI (лучше это делать в основном классе, а не в классе Dog*), написав:

final static double PI = 3.14;

Если ключевое слово final указать перед объявлением метода, это будет обозначать, что метод нельзя переопределять при наследовании (т.е. данная версия метода будет окончательной).

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

Диаграммы классов в языке UML

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

На этапе проектирования программного продукта, разрабатываемого в соответствии с объектно-ориентированной методологией, составляется диаграмма классов. Одним из стандартных средств для создания этих диаграмм является язык UML.

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

Основное задание практикума по Java содержит уже разработанные фрагменты UML-диаграмм (чтобы студенты могли сосредоточиться на программировании). Необходимо уметь в них разбираться.

Напомним основные обозначения, применяемые в диаграммах классов на примере UML-диаграммы системы LogisticSystem (см. задание 10).

Классы изображаются на диаграмме прямоугольниками, разделенными на три части. В верхней части указывается имя класса. В средней части перечисляются атрибуты (поля) класса, а в нижней — его методы. На рисунке изображены классы User, Admin, Driver, Map, Edge, LogisticSystem.

Закрытые (private) члены класса помечаются знаком минус или изображением замочка. На рисунке в классе LogisticSystem все поля и методы закрыты, кроме метода main(). У других классов закрытыми являются все поля (в соответствии с принципом инкапсуляции), а методы открыты.

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

findUser(login : String, password : String) : User

На языке Java заголовок этого метода на самом деле выглядит так:

public User findUser(String login, String password);

Однако UML не привязан к конкретному языку программирования, поэтому заголовки методов придется "переводить".

Наследование изображается в виде стрелки с полым белым наконечником, указывающей от класса-потомка к классу-предку. На рисунке классы Admin и Driver являются наследниками класса User. Унаследованные члены класса в классах-потомках не отображаются.

Между классами могут быть еще два вида отношений. Ассоциация — это связь между объектами двух классов, изображаемая прямой линией. Например, между классом LogisticSystem (система составления маршрутов) и классом Map (карта города) существует ассоциативная связь. В данном случае она означает, что система пользуется картой для выполнения своих функций. Агрегация — это отношение вида "часть/целое". Например, класс Edge (дорога между районами) является частью класса Map, то есть карта состоит из районов и связей между ними (как видно из рисунка, сами районы отдельным классом не представлены, они описываются с помощью обычных строк — см. атрибут points класса Edge). Агрегация изображается в виде линии с ромбом на конце (ромб ставится около класса, являющегося частью другого).

Отношения ассоциации и агрегации помогают понять взаимосвязи между разными классами. Эти взаимосвязи могут быть уже реализованы на диаграмме через атрибуты (поля) классов. Например, в классе Map есть атрибут edges, представляющий собой список ссылок на объекты класса Edge, а в класс LogisticSystem уже включен атрибут map для ссылки на карту, используемую системой. Поэтому при выполнении заданий следует обращать внимание на ассоциации и агрегации лишь для лучшего понимания модели.

Для лучшего понимания модели могут оказаться полезными мощности отношений, которые проставляются на концах линий. Рассмотрим агрегацию между классами Map и Edge. Около класса Map стоит число 1, которое означает, что объект Edge может являться частью только одной карты (что неудивительно, поскольку в программе предусмотрена единственная карта). Около класса Edge стоит 0..n. Это означает, что с объектом класса Map может быть связано произвольное число объектов класса Edge, в том числе ни одного.

Заметим, что на диаграмме опущены две ассоциативных связи между классами LogisticSystem и User. Первая связывает систему и зарегистрированных в ней пользователей (реализуется через атрибут users). Вторая связывает систему и текущего пользователя (реализуется через атрибут currentUser). Эти связи опущены для увеличения наглядности остальной диаграммы.

Литература по теме:

1. Терри Кватрани. Rational Rose 2000 и UML. Визуальное моделирование.


Дополнительная литература

1. Вязовик Н.А. Программирование на Java. (главы 6—8)

2. Хабибуллин И.Ш. Самоучитель Java 2. (глава 2)

Задание

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

Содержание | Рекомендации по изучению | Задания

Занятие 1 | Занятие 2 | Занятие 3 | Занятие 4 | Занятие 6 | Занятие 7 | Занятие 8 | Занятие 9 | Занятие 10 | Занятие 11