учебники, программирование, основы, введение в,

 

Объявление классов

Введение
Объявление классов является центральной темой курса, поскольку любая программа на Java – это набор классов. Поскольку типы являются ключевой конструкцией языка, их структура довольно сложна, имеет много тонкостей. Поэтому данная тема разделена на две лекции.
Эта лекция начинается с продолжения темы прошлой лекции – имена и доступ к именованным элементам языка. Необходимо рассмотреть механизм разграничения доступа в Java, как он устроен, для чего применяется. Затем будут описаны ключевые правила объявления классов.
Следующая лекция подробно рассматривает особенности объектной модели Java. Вводится понятие интерфейса. Уточняются правила объявления классов и описывается объявление интерфейса.
Модификаторы доступа
Во многих языках существуют права доступа, которые ограничивают возможность использования, например, переменной в классе. Например, легко представить два крайних вида прав доступа: это public, когда поле доступно из любой точки программы, и private, когда поле может использоваться только внутри того класса, в котором оно объявлено.
Однако прежде, чем переходить к подробному рассмотрению этих и других модификаторов доступа, необходимо внимательно разобраться, зачем они вообще нужны.
Предназначение модификаторов доступа
Очень часто права доступа расцениваются как некий элемент безопасности кода: мол, необходимо защищать классы от "неправильного" использования. Например, если в классе Human (человек) есть поле age (возраст человека), то какой-нибудь программист намеренно или по незнанию может присвоить этому полю отрицательное значение, после чего объект станет работать неправильно, могут появиться ошибки. Для защиты такого поля age необходимо объявить его private.
Это довольно распространенная точка зрения, однако нужно признать, что она далека от истины. Основным смыслом разграничения прав доступа является обеспечение неотъемлемого свойства объектной модели – инкапсуляции, то есть сокрытия реализации. Исправим пример таким образом, чтобы он корректно отражал предназначение модификаторов доступа. Итак, пусть в классе Human есть поле age целочисленного типа, и чтобы все желающие могли пользоваться этим полем, оно объявляется public.
public class Human {
public int age;
}
Проходит время, и если в группу программистов, работающих над системой, входят десятки разработчиков, логично предположить, что все, или многие, из них начнут использовать это поле.
Может получиться так, что целочисленного типа данных будет уже недостаточно и захочется сменить тип поля на дробный. Однако если просто изменить int на double, вскоре все разработчики, которые пользовались классом Human и его полем age, обнаружат, что в их коде появились ошибки, потому что поле вдруг стало дробным, и в строках, подобных этим:
Human h = getHuman();   // получаем ссылку
int i=h.age;   // ошибка!!
будет возникать ошибка из-за попытки провести неявным образом сужение примитивного типа.
Получается, что подобное изменение (в общем, небольшое и локальное) потребует модификации многих и многих классов. Поэтому внесение его окажется недопустимым, неоправданным с точки зрения количества усилий, которые необходимо затратить. То есть, объявив один раз поле или метод как public, можно оказаться в ситуации, когда малейшие изменения (имени, типа, характеристик, правил использования) в дальнейшем станут невозможны.
Напротив, если бы поле было объявлено как private, а для чтения и изменения его значения были бы введены дополнительные методы, ситуация поменялась бы в корне:
public class Human {
private int age;
// метод, возвращающий значение age
public int getAge() {
return age;
}
// метод, устанавливающий значение age
public void setAge(int a) {
age=a;
}
}
В этом случае с данным классом могло бы работать множество программистов и могло быть создано большое количество классов, использующих тип Human, но модификатор private дает гарантию, что никто напрямую этим полем не пользуется и изменение его типа было бы совсем несложной операцией, связанной с изменением только в одном классе.
Получение величины возраста выглядело бы следующим образом:

Human h = getHuman();
int i=h.getAge();   // обращение через метод
Рассмотрим, как выглядит процесс смены типа поля age:
public class Human {

   // поле получает новый тип double
private /*int*/ double age;

   // старые методы работают с округлением
// значения

public int getAge() {
return (int)Math.round(age);
}
public void setAge(int a) {
age=a;
}
// добавляются новые методы для работы
// с типом double

public double getExactAge() {
return age;
}
public void setExactAge(double a) {
age=a;
}
}
Видно, что старые методы, которые, возможно, уже применяются во многих местах, остались без изменения. Точнее, остался без изменений их внешний формат, а внутренняя реализация усложнилась. Но такая перемена не потребует никаких модификаций остальных классов системы. Пример использования
Human h = getHuman();
int i=h.getAge();   // корректно
остается верным, переменная i получает корректное целое значение. Однако изменения вводились для того, чтобы можно было работать с дробными величинами. Для этого были добавлены новые методы и во всех местах, где требуется точное значение возраста, необходимо обращаться к ним:
Human h = getHuman();
double d=h.getExactAge();  
// точное значение возраста
Итак, в класс была добавлена новая возможность, не потребовавшая никаких изменений кода.
За счет чего была достигнута такая гибкость? Необходимо выделить свойства объекта, которые потребуются будущим пользователям этого класса, и сделать их доступными (в данном случае, public). Те же элементы класса, что содержат детали внутренней реализации логики класса, желательно скрывать, чтобы не образовались нежелательные зависимости, которые могут сдерживать развитие системы.
Этот пример одновременно иллюстрирует и другое теоретическое правило написания объектов, а именно: в большинстве случаев доступ к полям лучше реализовывать через специальные методы (accessors) для чтения (getters) и записи (setters). То есть само поле рассматривается как деталь внутренней реализации. Действительно, если рассматривать внешний интерфейс объекта как целиком состоящий из допустимых действий, то доступными элементами должны быть только методы, реализующие эти действия. Один из случаев, в котором такой подход приносит необходимую гибкость, уже рассмотрен.
Есть и другие соображения. Например, вернемся к вопросу о корректном использовании объекта и установки верных значений полей. Как следствие, правильное разграничение доступа позволяет ввести механизмы проверки входных значений:
public void setAge(int a) {
if (a>=0) {
age=a;
}
}
В этом примере поле age никогда не примет некорректное отрицательное значение. (Недостатком приведенного примера является то, что в случае неправильных входных данных они просто игнорируются, нет никаких сообщений, позволяющих узнать, что изменения поля возраста на самом деле не произошло; для полноценной реализации метода необходимо освоить работу с ошибками в Java.)
Бывают и более существенные изменения логики класса. Например, данные можно начать хранить не в полях класса, а в более надежном хранилище, например, файловой системе или базе данных. В этом случае методы-аксессоры опять изменят свою реализацию и начнут обращаться к persistent storage (постоянное хранилище, например, БД) для чтения/записи значений. Если доступа к полям класса не было, а открытыми были только методы для работы с их значениями, то можно изменить код этих методов, а наружные типы, которые использовали данный класс, совершенно не изменятся, логика их работы останется той же.
Подведем итоги. Функциональность класса необходимо разделять на открытый интерфейс, описывающий действия, которые будут использовать внешние типы, и на внутреннюю реализацию, которая применяется только внутри самого класса. Внешний интерфейс в дальнейшем модифицировать невозможно, или очень сложно, для больших систем, поэтому его требуется продумывать особенно тщательно. Детали внутренней реализации могут быть изменены на любом этапе, если они не меняют логику работы всего класса. Благодаря такому подходу реализуется одна из базовых характеристик объектной модели — инкапсуляция, и обеспечивается важное преимущество технологии ООП — модульность.
Таким образом, модификаторы доступа вводятся не для защиты типа от внешнего пользователя, а, напротив, для защиты, или избавления, пользователя от излишних зависимостей от деталей внутренней реализации. Что же касается неправильного применения класса, то его создателям нужно стремиться к тому, чтобы класс был прост в применении, тогда таких проблем не возникнет, ведь программист не станет намеренно писать код, который порождает ошибки в его программе.
Конечно, такое разбиение на внешний интерфейс и внутреннюю реализацию не всегда очевидно, часто условно. Для облегчения задачи технических дизайнеров классов в Java введено не два (public и private), а четыре уровня доступа. Рассмотрим их и весь механизм разграничения доступа в Java более подробно.
Разграничение доступа в Java
Уровень доступа элемента языка является статическим свойством, задается на уровне кода и всегда проверяется во время компиляции. Попытка обратиться к закрытому элементу вызовет ошибку.
В Java модификаторы доступа указываются для:

  • типов (классов и интерфейсов) объявления верхнего уровня;
  • элементов ссылочных типов (полей, методов, внутренних типов);
  • конструкторов классов.

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

  • public;
  • private;
  • protected;
  • если не указан ни один из этих трех типов, то уровень доступа определяется по умолчанию (default).

Первые два из них уже были рассмотрены. Последний уровень (доступ по умолчанию) упоминался в прошлой лекции – он допускает обращения из того же пакета, где объявлен и сам этот класс. По этой причине пакеты в Java являются не просто набором типов, а более структурированной единицей, так как типы внутри одного пакета могут больше взаимодействовать друг с другом, чем с типами из других пакетов.
Наконец, protected дает доступ наследникам класса. Понятно, что наследникам может потребоваться доступ к некоторым элементам родителя, с которыми не приходится иметь дело внешним классам.
Однако описанная структура не позволяет упорядочить модификаторы доступа так, чтобы каждый следующий строго расширял предыдущий. Модификатор protected может быть указан для наследника из другого пакета, а доступ по умолчанию допускает обращения из классов-ненаследников, если они находятся в том же пакете. По этой причине возможности protected были расширены таким образом, что он включает в себя доступ внутри пакета. Итак, модификаторы доступа упорядочиваются следующим образом (от менее открытых – к более открытым):
private
(none) default
protected
public
Эта последовательность будет использована далее при изучении деталей наследования классов.
Теперь рассмотрим, какие модификаторы доступа возможны для различных элементов языка.

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

Для типов объявления верхнего уровня нет необходимости во всех четырех уровнях доступа. Private-типы образовывали бы закрытую мини-программу, никто не мог бы их использовать. Типы, доступные только для наследников, также не были признаны полезными.
Разграничения доступа сказываются не только на обращении к элементам объектных типов или пакетов (через составное имя или прямое обращение), но также при вызове конструкторов, наследовании, приведении типов. Импортировать недоступные типы запрещается.
Проверка уровня доступа проводится компилятором. Обратите внимание на следующие примеры:
public class Wheel {
private double radius;
public double getRadius() {
return radius;
}
}
Значение поля radius недоступно снаружи класса, однако открытый метод getRadius() корректно возвращает его.
Рассмотрим следующие два модуля компиляции:
package first;

// Некоторый класс Parent
public class Parent {
}

package first;

// Класс Child наследуется от класса Parent,
// но имеет ограничение доступа по умолчанию
class Child extends Parent {
}

public class Provider {
public Parent getValue() {
return new Child();
}
}
К методу getValue() класса Provider можно обратиться и из другого пакета, не только из пакета first, поскольку метод объявлен как public. Данный метод возвращает экземпляр класса Child, который недоступен из других пакетов. Однако следующий вызов является корректным:

package second;

import first.*;

public class Test {
public static void main(String s[])
{
Provider pr = new Provider();
Parent p = pr.getValue();
System.out.println(p.getClass().getName());
// (Child)p - приведет к ошибке компиляции!
}
}
Результатом будет:
first.Child
То есть на самом деле в классе Test работа идет с экземпляром недоступного класса Child, что возможно, поскольку обращение к нему делается через открытый класс Parent. Попытка же выполнить явное приведение вызовет ошибку. Да, тип объекта "угадан" верно, но доступ к закрытому типу всегда запрещен.
Следующий пример:
public class Point {
private int x, y;

  public boolean equals(Object o) {
if (o instanceof Point) {
Point p = (Point)o;
return p.x==x && p.y==y;
}
return false;
}
}
В этом примере объявляется класс Point с двумя полями, описывающими координаты точки. Обратите внимание, что поля полностью закрыты – private. Далее попытаемся переопределить стандартный метод equals() таким образом, чтобы для аргументов, являющихся экземплярами класса Point, или его наследников (логика работы оператора instanceof), в случае равенства координат возвращалось истинное значение. Обратите внимание на строку, где делается сравнение координат,– для этого приходится обращаться к private-полям другого объекта!
Тем не менее, такое действие корректно, поскольку private допускает обращения из любой точки класса, независимо от того, к какому именно объекту оно производится.
Другие примеры разграничения доступа в Java будут рассматриваться по ходу курса.

Объявление классов
Рассмотрим базовые возможности объявления классов.
Объявление класса состоит из заголовка и тела класса.
Заголовок класса
Вначале указываются модификаторы класса. Модификаторы доступа для класса уже обсуждались. Допустимым является public, либо его отсутствие – доступ по умолчанию.
Класс может быть объявлен как final. В этом случае не допускается создание наследников такого класса. На своей ветке наследования он является последним. Класс String и классы-обертки, например, представляют собой final-классы.
После списка модификаторов указывается ключевое слово class, а затем имя класса – корректный Java-идентификатор. Таким образом, кратчайшим объявлением класса может быть такой модуль компиляции:
class A {}
Фигурные скобки обозначают тело класса, но о нем позже.
Указанный идентификатор становится простым именем класса. Полное составное имя класса строится из полного составного имени пакета, в котором он объявлен (если это не безымянный пакет), и простого имени класса, разделенных точкой. Область видимости класса, где он может быть доступен по своему простому имени, – его пакет.
Далее заголовок может содержать ключевое слово extends, после которого должно быть указано имя (простое или составное) доступного не-final класса. В этом случае объявляемый класс наследуется от указанного класса. Если выражение extends не применяется, то класс наследуется напрямую от Object. Выражение extends Object допускается и игнорируется.
class Parent {}
// = class Parent extends Object {}

final class LastChild extends Parent {}

   // class WrongChild extends LastChild {}
// ошибка!!
Попытка расширить final-класс приведет к ошибке компиляции.
Если в объявлении класса A указано выражение extends B, то класс A называют прямым наследником класса B.
Класс A считается наследником класса B, если:

  • A является прямым наследником B;
  • существует класс C, который является наследником B, а A является наследником C (это правило применяется рекурсивно).

Таким образом можно проследить цепочки наследования на несколько уровней вверх.
Если компилятор обнаруживает, что класс является своим наследником, возникает ошибка компиляции:
// пример вызовет ошибку компиляции
class A extends B {}
class B extends C {}
class C extends A {}
// ошибка! Класс А стал своим наследником
Далее в заголовке может быть указано ключевое слово implements, за которым должно следовать перечисление через запятую имен (простых или составных, повторения запрещены) доступных интерфейсов:
public final class String implements
Serializable, Comparable {}
В этом случае говорят, что класс реализует перечисленные интерфейсы. Как видно из примера, класс может реализовывать любое количество интерфейсов. Если выражение implements отсутствует, то класс действительно не реализует никаких интерфейсов, здесь значений по умолчанию нет.
Далее следует пара фигурных скобок, которые могут быть пустыми или содержать описание тела класса.
Тело класса
Тело класса может содержать объявление элементов (members) класса:

  • полей;
  • внутренних типов (классов и интерфейсов);

и остальных допустимых конструкций:

  • конструкторов;
  • инициализаторов
  • статических инициализаторов.

Элементы класса имеют имена и передаются по наследству, не-элементы – нет. Для элементов простые имена указываются при объявлении, составные формируются из имени класса, или имени переменной объектного типа, и простого имени элемента. Областью видимости элементов является все объявление тела класса. Допускается применение любого из всех четырех модификаторов доступа. Напоминаем, что соглашения по именованию классов и их элементов обсуждались в прошлой лекции.
Не-элементы не обладают именами, а потому не могут быть вызваны явно. Их вызывает сама виртуальная машина. Например, конструктор вызывается при создании объекта. По той же причине не-элементы не обладают модификаторами доступа.
Элементами класса являются элементы, описанные в объявлении тела класса и переданные по наследству от класса-родителя (кроме Object – единственного класса, не имеющего родителя) и всех реализуемых интерфейсов при условии достаточного уровня доступа. Таким образом, если класс содержит элементы с доступом по умолчанию, то его наследники из разных пакетов будут обладать разным набором элементов. Классы из того же пакета могут пользоваться полным набором элементов, а из других пакетов – только protected и public. private-элементы по наследству не передаются.
Поля и методы могут иметь одинаковые имена, поскольку обращение к полям всегда записывается без скобок, а к методам – всегда со скобками.
Рассмотрим все эти конструкции более подробно.
Объявление полей
Объявление полей начинается с перечисления модификаторов. Возможно применение любого из трех модификаторов доступа, либо никакого вовсе, что означает уровень доступа по умолчанию.
Поле может быть объявлено как final, это означает, что оно инициализируется один раз и больше не будет менять своего значения. Простейший способ работы с final-переменными - инициализация при объявлении:
final double PI=3.1415;
Также допускается инициализация final-полей в конце каждого конструктора класса.
Не обязательно использовать для инициализации константы компиляции, возможно обращение к различным функциям, например:
final long creationTime =
System.currentTimeMillis();
Данное поле будет хранить время создания объекта. Существует еще два специальных модификатора - transient и volatile. Они будут рассмотрены в соответствующих лекциях.
После списка модификаторов указывается тип поля. Затем идет перечисление одного или нескольких имен полей с возможными инициализаторами:
int a;
int b=3, c=b+5, d;
Point p, p1=null, p2=new Point();
Повторяющиеся имена полей запрещены. Указанный идентификатор при объявлении становится простым именем поля. Составное имя формируется из имени класса или имени переменной объектного типа, и простого имени поля. Областью видимости поля является все объявление тела класса.
Запрещается использовать поле в инициализации других полей до его объявления.
int y=x;
int x=3;
Однако в остальном поля можно объявлять и ниже их использования:
class Point {
int getX() {return x;}

   int y=getX();
int x=3;

   public static void main (String s[]) {
Point p=new Point();
System.out.println(p.x+", "+p.y);
}
}
Результатом будет:
3, 0
Данный пример корректен, но для понимания его результата необходимо вспомнить, что все поля класса имеют значение по умолчанию:

  • для числовых полей примитивных типов – 0;
  • для булевского типа – false;
  • для ссылочных – null.

Таким образом, при инициализации переменной y был использован результат метода getX(), который вернул значение по умолчанию переменной x, то есть 0. Затем переменная x получила значение 3.

 
На главную | Содержание | < Назад....Вперёд >
С вопросами и предложениями можно обращаться по nicivas@bk.ru. 2013 г.Яндекс.Метрика