Классы, суперклассы и подклассы

From AsIsWiki
Jump to: navigation, search
Форум

Назад | Оглавление | Дальше


Contents

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

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


Классы, суперклассы и подклассы

Вернемся к классу Employee. Предположим, что работа менеджеров компании учитывается иначе, чем работа остальных сотрудников. Сотрудникам выплачивается заработная плата, а менеджерам начисляются дополнительные бонусы. В таких ситуациях применяется наследование. Нужно определить новый класс Manager, содержащий ряд методов и полей. Некоторые из этих методов и полей уже определены в классе Employee. Причем, все поля этого класса подходят и для класса Manager. Говоря абстрактно, между классами Manager и Employee существует очевидное отношение "is-a" ("является"). Каждый менеджер является сотрудником. Отношение "является" - признак наследования.

Пример определения класса Manager, порожденного из класса Employee. Для обозначения наследования в Java используется ключевое слово extends:

class Manager extends Employee {
    // дополнительные методы и поля
}

Механизмы наследования в Java и C++ похожи. В Java вместо символов :: используется ключевое слово extends. Любое наследование в Java является открытым, причем аналога закрытому и защищенному наследованию C++, в Java нет.

Существующий класс называется суперклассом, базовым или родительским. Порожденный класс - подкласс, производный или дочерний. Среди Java-разработчиков более распространены термины "суперкласс" и "подкласс". Некоторые специалисты предпочитают понятия "родительский/дочерний", более тесно связанные со словом "наследование".

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

Префиксы супер- и под- пришли в программирование из теории множеств. Superset - супермножество всех сотрудников содержит в себе subset - подмножество всех менеджеров.

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

class Manager extends Employee {
    ...
    public void setBonus(double b) {
        bonus = b;
    }

    private double bonus;
}

В этих методах и полях нет ничего особенного. Имея объект Manager, можно просто применять метод setBonus():

Manager boss = ... ;

boss.setBonus(5000);

С помощью объекта Employee, метод setBonus() вызвать невозможно - его нет среди методов этого класса. Однако, пользуясь объектами Manager, можно вызывать методы getName() и getHireDay(), поскольку они наследуются от суперкласса Employee.

Каждый объект Manager наследует от суперкласса четыре поля: name, salary, hireDay и bonus. Определяя подкласс, нужно указать лишь отличия между ним и суперклассом. Разрабатывая классы, следует помещать методы общего назначения в суперкласс, а более специальные методы - в подкласс. Такое выделение функциональных свойств общего назначения путем их размещения в суперклассе широко распространено в ООП.

Некоторые методы суперкласса не подходят для подкласса Manager. В частности, метод getSalary должен возвращать сумму базовой зарплаты и премии. Следовательно, нужно переопределить метод, т.е. реализовать новый метод, замещающий метод суперкласса:

class Manager extends Employee {
    ...
    public void getSalary() {
        ...
    }
    ...
}

На первый взгляд задача выглядит просто - нужно лишь вернуть сумму полей salary и bonus:

public void getSalary() {

    return salary + bonus;  // не работает

}

Метод getSalary() класса Manager не имеет доступа к закрытым полям суперкласса. Несмотря на то, что каждый объект Manager имеет поле salary, метод getSalary() класса Manager не может обратиться к нему непосредственно. Только методы класса Employee имеют доступ к закрытым полям своего класса.

Если методу подкласса Manager нужен доступ к закрытым полям суперкласса Employee, он должен использовать открытый интерфейс суперкласса. Вместо прямого обращения к полю salary нужно вызвать метод getSalary() класса Employee:

public void getSalary() {
    double baseSalary = getSalary();  // все еще не работает
    return baseSalary + bonus;
}

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

Нужно явно указать, что мы хотим вызвать метод getSalary() из суперкласса Employee, а не из текущего класса. Для этой цели используется специальное ключевое слово super. Приведенное ниже выражение вызывает метод getSalary() именно из класса Employee:

super.getSalary()

Правильный вариант метода getSalary() для класса Manager выглядит так:

public void getSalary() {
    double baseSalary = super.getSalary();
    return baseSalary + bonus;
}

Некоторые считают ключевое слово super аналогом ссылки this. Однако, это не так - super не является ссылкой на объект. Его значение нельзя присвоить другой объектной переменной. Это слово лишь сообщает компилятору, что нужно вызвать метод суперкласса.

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

В Java для вызова метода суперкласса используется ключевое слово super. В C++ для этого используют имя суперкласса вместе с оператором разрешения области видимости, например так: Employee::getSalary()

В заключение напишем конструктор класса:

public Manager(String n, double s, int year, int month, int day) {
    super(n, s, year, month, day);
    bonus = 0;
}

Здесь ключевое слово super имеет другой смысл. Приведенное ниже выражение означает вызов конструктора суперкласса Employee с параметрами n, s, year, month и day:

super(n, s, year, month, day);

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

Если конструктор подкласса не обращается к конструктору суперкласса, то автоматически стартует конструктор по умолчанию суперкласса. Если в данной ситуации суперкласс не имеет конструктора по умолчанию, то компилятор сообщит об ошибке.

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

В C++ вызов конструктора super не применяется. Вместо этого для создания суперкласса используется список инициализации. В C++ конструктор класса Manager выглядел бы так:

Manager::Manager(String n, double s, int year, int month, int day)
: Employee(n, s, year, MONTH, DAY) {
    bonus = 0;
}

После переопределения метода getSalary() в классе Manager, все менеджеры получат премии:

Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);

boss.setBonus(5000);

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

Employee[] staff = new Employee[3];

Заполним его объектами Manager и Employee:

staff[0] = boss;

staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);

staff[2] = new Employee("Tommy Tester", 40000, 1990, 3, 15);

Выведем зарплату каждого сотрудника:

for (Employee e : staff) {
    System.out.println(e.getName() + " " + e.getSalary());
}

В результате выполнения цикла, отображаются строки:

Carl Cracker 85000.0
Harry Hacker 50000.0
Tommy Tester 40000.0

Поскольку staff[1] и staff[2] являются объектами Employee, они выводят базовые зарплаты сотрудников. Объект staff[0] относится к классу Manager, и его метод getSalary() добавляет к базовой зарплате премию.

Обратим особое внимание на вызов:

e.getSalary()

Переменная е объявлена как Employee. Но фактическим типом объекта, на который она ссылается, может быть как класс Employee (i = [1, 2]), так и класс Manager (i = 0).

Когда e ссылается на объект Employee, выражение e.getSalary() вызывает метод getSalary() класса Employee. Но если переменная e ссылается на объект Manager, вызывается метод getSalary() класса Manager. Виртуальной машине известен фактический тип объекта, на который ссылается переменная, поэтому с вызовом метода проблем не возникает.

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

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

В следующем листинге представлен код, демонстрирующий подсчет заработной платы с использованием объектов Employee и Manager:

import java.util.*;

public class ManagerTest {

    public static void main(String[] args) {
        // construct a Manager object
        Manager boss = new Manager("Carl Cracker", 80000, 1987, 12, 15);
        boss.setBonus(5000);

        Employee[] staff = new Employee[3];

        // fill the staff array with Manager and Employee objects
        staff[0] = boss;
        staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
        staff[2] = new Employee("Tommy Tester", 40000, 1990, 3, 15);

        // print out information about all Employee objects
        for (Employee e : staff) {
            System.out.println("name=" + e.getName() + ", salary=" + e.getSalary());
        }
    }
}

class Employee {

    public Employee(String n, double s, int year, int month, int day) {
        name = n;
        salary = s;
        GregorianCalendar calendar = new GregorianCalendar(year, month - 1, day);
        hireDay = calendar.getTime();
    }

    public String getName() {
        return name;
    }

    public double getSalary() {
        return salary;
    }

    public Date getHireDay() {
        return hireDay;
    }

    public void raiseSalary(double byPercent) {
        double raise = salary * byPercent / 100;
        salary += raise;
    }

    private String name;
    private double salary;
    private Date hireDay;
}

class Manager extends Employee {

    /**
     * @param n the employee's name
     * @param s the salary
     * @param year the hire year
     * @param month the hire month
     * @param day the hire day
     */
    public Manager(String n, double s, int year, int month, int day) {
        super(n, s, year, month, day);
        bonus = 0;
    }

    public double getSalary() {
        double baseSalary = super.getSalary();
        return baseSalary + bonus;
    }

    public void setBonus(double b) {
        bonus = b;
    }

    private double bonus;
}


Иерархия наследования

Наследование не обязательно ограничивается одним уровнем классов. Например, на основе класса Manager можно создать подкласс Executive. Совокупность всех классов, производных от общего суперкласса, называется inheritance hierarchy - иерархия наследования. Путь от класса к его потомкам в иерархии называется inheritance chain - цепочка наследования. Ниже представлена схема, иллюстрирующая иерархию наследования для класса Employee:

Inher hierarchy.png

Обычно для класса существует несколько цепочек наследования. На основе класса Employee можно создать подкласс Programmer или Secretary, причем они будут независимы друг от друга. Процесс формирования подклассов можно продолжать сколь угодно долго.

В Java множественное наследование не поддерживается. Типичные для множественного наследования задачи решаются в Java с помощью интерфейсов.


Полиморфизм

Если между объектами существует отношение is-a ("является"), то каждый объект подкласса является объектом суперкласса. Например, каждый менеджер является сотрудником. Следовательно, имеет смысл сделать класс Manager подклассом класса Employee. Естественно, обратное утверждение не верно - не каждый сотрудник является менеджером.

Другой способ - substitution principle (принцип подстановки). Этот принцип гласит, что объект подкласса можно использовать вместо любого объекта суперкласса.

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

Employee e;

e = new Employee( ... );  // объект класса Employee

e = new Manager( ... );   // можно и так

В Java объектные переменные являются полиморфными - polymorphic. Переменная типа Employee может ссылаться как на объект Employee, так и на объект любого подкласса класса Employee (например, Manager, Executive, Secretary и т.п.).

Применение этого принципа продемонстрировано в предыдущем листинге.

Manager boss = new Manager( ... );
Employee[] staff = new Employee[3];
staff[0] = boss;

Переменные staff[0] и boss ссылаются на один и тот же объект. Однако переменная staff[0] рассматривается компилятором только как объект Employee:

staff[0].setBonus(5000);  // Error!

boss.setBonus(5000);      // OK

Переменная staff[0] объявлена как объект Employee, а метода setBonus() в этом классе нет.

Объект суперкласса может получать ссылки на объекты подклассов, но обратное действие невозможно. Переменным подкласса запрещено получать ссылки на объекты суперкласса:

Manager m = staff[i];  // Error!

Причина очевидна: не все сотрудники являются менеджерами. Если переменная m сможет ссылаться на объект Employee, то появится возможность вызвать метод setBonus() для рядового сотрудника, что приведет к ошибке выполнения программы.

Массив ссылок на объекты подкласса можно преобразовать в массив ссылок на объекты суперкласса. Рассмотрим преобразование массива Manager[] в массив ссылок Employee[]:

Manager[] managers = new Manager[10];

Employee[] staff = managers;  // OK

Каждый менеджер является сотрудником. Поэтому, объект Manager содержит все поля и методы объекта Employee. Однако, ссылка переменных manager и staff на единый массив, может вызвать неприятные последствия. Рассмотрим следующее выражение:

staff[0] = new Employee("Harry Hacker", ... );

Компилятор допускает подобное действие. Но, staff[0] и manager[0] представляют единую ссылку. Это выглядит, как попытка присвоить переменной, соответствующей менеджеру, ссылку на рядового сотрудника. Становится возможным мызов managers[0].setBonus(1000), что повлечет обращение к несуществующему методу и разрушение содержимого памяти.

Для избежания подобных ситуаций, в массиве запоминается тип элементов и отслеживается допустимость хранящихся ссылок. Например, массив new Manager[10] рассматривается как массив элементов Manager, и попытка записать в него ссылку на объект Employee приводит к исключению ArrayStoreException.


Динамическое связывание

Действия, выполняемые при вызове метода, принадлежащего некоторому объекту:

  1. Компилятор проверяет объявленный тип объекта и имя метода.
    Допустим, происходит вызов метода x.f(args), причем неявный параметр x объявлен как экземпляр класса C. Заметим, что может существовать несколько методов с именем f, имеющих разные типы параметров, например: f(int) и метод f(String). Компилятор нумерует все методы с именем f в классе C и все общедоступные методы с именем f в суперклассах класса C.
    Теперь компилятор знает всех возможных кандидатов при вызове метода.
  2. Компилятор определяет типы параметров, указанных при вызове метода. Если среди всех методов с именем f есть только один метод, типы параметров которого совпадают с указанными, происходит его вызов. Этот процесс называется разрешением перегрузки - overloading resolution.
    Например, при вызове x.f("Hello") компилятор выберет метод f(String), а не метод f(int). Ситуация может осложниться вследствие преобразования типов (int - в double, Manager - в Employee и т.д.). Если компилятор не находит ни одного метода с подходящим набором параметров или в результате преобразования типов возникает несколько методов, соответствующих данному вызову, выдается сообщение об ошибке.
    Теперь компилятор знает имя и типы параметров метода, подлежащего вызову.
    Напомним, что имя метода и список типов его параметров образуют сигнатуру метода - signature.. Например, методы f(int) и f(String) имеют одинаковые имена, но разные сигнатуры. Если в подклассе определен метод, сигнатура которого совпадает с сигнатурой некоторого метода суперкласса, то метод подкласса замещает его собой.
    Однако возвращаемое значение в сигнатуре не учитывается. В версиях, предшествующих JDK 5.0, тип возвращаемого значения переопределяемого и переопределяющего методов должны были совпадать. Однако теперь допускается, чтобы в подклассе в качестве возвращаемого значения использовался подтип первоначально выбранного типа. Предположим, например, что в классе Employee определен метод getBuddy():
    public Employee getBuddy() { ... }
    

    В подклассе Manager этот метод переопределен следующим образом:

    public Manager getBuddy() { ... }  // корректно для JDK 5.0
    

    При этом считается, что для методов getBuddy() определены ковариантные возвращаемые типы.

  3. Если метод является закрытым (private), статическим (static), терминальным (final) или конструктором, компилятор точно знает, как его вызывать. Модификатор final описывается в следующем разделе. Такой процесс называется статическим связыванием - static binding. В противном случае метод, подлежащий вызову, определяется по фактическому типу неявного параметра и во время выполнения программы используется динамическое связывание - dynamic binding. В нашем примере компилятор сгенерировал бы вызов метода f(String) с помощью динамического связывания.
  4. Если при выполнении программы для вызова метода используется динамическое связывание, виртуальная машина должна вызвать версию метода, соответствующую фактическому типу объекта, на который ссылается переменная x. Предположим, что объект имеет фактический тип D, являющийся суперклассом класса C. Если в классе D определен метод f(String), то вызывается именно он. Если нет, то поиск метода f(String), подлежащего вызову, выполняется в суперклассе и т.д.
    На поиск вызываемого метода уходит слишком много времени, поэтому виртуальная машина заранее знает таблицу методов - method table - для каждого класса, в которой перечисляются сигнатуры всех методов и фактические методы, подлежащие вызову. При вызове метода виртуальная машина просто просматривает таблицу методов. В нашем примере виртуальная машина проверяет таблицу методов класса D и обнаруживает метод f(String), подлежащий вызову. Такими методами могут быть D.f(String) или X.f(String), если X - некоторый суперкласс класса D.
    В этом сценарии есть одна особенность. Если вызывается метод super.f(args), то компилятор просматривает таблицу методов суперкласса неявного параметра.

Рассмотрим подробно вызов метода e.getSalary() из предыдущего листинга. Переменная e имеет тип Employee. В этом классе есть только один метод getSalary(), у которого не параметров. Следовательно, в данном случае можно не беспокоиться о разрешении перегрузки.

Поскольку при объявлении метода getSalary() не использовались ключевые слова private, static или final, он связывается динамически. Виртуальная машина создает таблицу методов классов Employee и Manager. Таблица класса Employee показывает, что все методы определены в самом классе.

Employee:
  getName() -> Employee.getName()
  getSalary() -> Employee.getSalary()
  getHireDay() -> Employee.getHireDay()
  raiseSalary(double) -> Employee.raiseSalary(double)

На самом деле это не совсем так. Как мы увидим позднее, класс Employee имеет суперкласс Object, от которого он наследует больше количество методов; их мы пока будем игнорировать.

Таблица методов класса Manager имеет несколько иной вид. В этом классе три метода наследуются, один метод - переопределяется и один - добавляется.

Manager:
  getName() -> Employee.getName()
  getSalary() -> Manager.getSalary()
  getHireDay() -> Employee.getHireDay()
  raiseSalary(double) -> Employee.raiseSalary(double)
  setBonus(double) -> Manager.setBonus(double)

Во время выполнения программы вызов метода e.getSalary() осуществляется следующим образом:

  1. Сначала виртуальная машина загружает таблицу, соответствующую реальному типу переменной e. Это может быть таблица для Employee, Manager или другого подкласса класса Employee.
  2. Затем виртуальная машина определяет класс, в котором определен метод getSalary() с соответствующей сигнатурой. Теперь метод, подлежащий вызову, известен.
  3. Виртуальная машина вызывает этот метод.

Динамическое связывание обладает одной важной особенностью: оно позволяет модифицировать программы без перекомпиляции их кодов. Это делает программы динамически расширяемыми - extensible. Допустим, в программу добавлен новый класс Executive, и переменная e может ссылаться на объект этого класса. Код, содержащий вызов метода e.getSalary(), заново компилировать не нужно. Если переменная e ссылается на объект типа Executive, автоматически вызывается метод Executive.getSalary().

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


Предотвращение наследования: терминальные классы и методы

Иногда наследование является нежедательным. Классы, которые нельзя расширить, называются терминальными - final. Для того чтобы отметить этот факт, в определении класса используется ключевое слово final. Предположим, например, что мы хотим предотвратить создание классов класса Executive. В этом случае класс определяется следующим образом:

final class Executive extends Manager {
    ...
}

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

class Employee {
    ...
    public final String getName() {
        return name;
    }
    ...
}

Напомним, что с помощью модификатора final могут также описываться поля, являющиеся константами. После создания объекта значение такого поля нельзя изменить. Однако, если класс объявлен как final, терминальными становятся только методы; поля в константы не превращаются.

Существует единственный аргумент в пользу применения ключевого слова final при объявлении метода или класса: это позволяет гарантировать неизменность семантики. Так, например, в классе Calendar методы getTime() и setTime() являются терминальными. Поступая таким образом, разработчики берут на себя ответственность за корректность преобразования содержимого объекта Date в состояние календаря. В подклассах невозможно изменить принцип преобразования. В качестве другого примера можно привести класс String. Создавать подклассы этого класса запрещено. Поэтому если у вас есть переменная типа String, можете быть уверены, что она ссылается именно на строку и ни на какой другой объект.

Некоторые программисты считают, что ключевое слово final надо применять при объявлении всех методов. Исключением являются только те случаи, когда есть веские основания для использования полиморфизма. Действительно, в методах C++ и C# для применения полиморфизма надо принять специальные меры. Может быть, указанное правило слишком жесткое, но, несомненно, что при формировании иерархии классов надо серьезно заботиться об использовании терминальных методов.

На заре развития Java некоторые программисты пытались использовать ключевое слово final для того, чтобы исключить накладные расходы,вызываемые динамическим связыванием. Если метод не переопределяется и размеры его невелики, компилятор применяет процедуру оптимизации, которая состоит в непосредственном включении кода. Например, вызов метода e.getName() заменяется обращением к полю e.name. Такое преобразование достаточно эффективно, так как ветвление программы несовместимо с упреждающей загрузкой команд, применяемой в процессорах. Следует заметить, что если метод getName() будет переопределен, компилятор не сможет непосредственно включить его код, поскольку ему станет неизвестно, какой из методов должен быть вызван в процессе выполнения программы.

Компонент виртуальной машины, называемый динамическим компилятором - just-intime compiler, может выполнять оптимизацию более эффективно, чем обыкновенный компилятор. Ему в точности известно, какие классы расширяют данный класс, и он может проверить, действительно ли метод переопределяется. Если размеры метода невелики, он часто вызывается и не переопределен, динамический компилятор выполнит непосредственное включение кода. Если же виртуальная машина загрузит подкласс, в котором метод переопределяется, непосредственное включение будет отменено. В таких ситуациях выполнение программы существенно замедляется, но возникают они редко.

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


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

Ранее мы рассмотрели процесс принудительного преобразования одного типа в другой, называемый приведением типов - casting. Для этого в Java существует специальное обозначение. Например, при выполнении следующего фрагмента кода значение переменной x преобразуется в целое число путем отбрасывания дробной части:

double x = 3.405;

int nx = (int) x;

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

Manager boss = (Manager) staff[0];

Для приведения типов существует только одна причина - необходимость использовать все возможности объекта после того, как его фактический тип был на время забыт. Например, в классе ManagerTest массив staff представляет собой массивобъектов Employee. Этот тип выбран, поскольку некоторые элементы массива хранят информацию об обычных сотрудниках. Чтобы получить доступ ко всем новым полям класса Manager, нам может понадобиться привести тип некоторых элементов массива к типу Manager. В примере, рассмотренном выше, мы предприняли специальные меры, чтобы избежать приведения типов. Мы инициализировали переменную boss объектом Manager, перед тем как поместить ее в массив. Для того чтобы задать размер премии, нужно знать правильный тип объекта.

Как известно, в Java тип объектной переменной определяет разновидность объектов, на которые может ссылаться эта переменная. Например, переменная staff[1] ссылается на объект Employee (поэтому она может ссылаться и на объекты Manager).

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

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

Manager boss = (Manager) staff[1];  // Error!

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

if (staff[i] instanceof Manager) {
    boss = (Manager) staff[1];
    ...
}

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

Date c = (Date) staff[1];

Таким образом, можно сформулировать основные правила приведения типов:

  • Приведение типов можно выполнять только в иерархии наследования.
  • Чтобы проверить корректность приведения суперкласса к подклассу, используйте оператор instanceof.

Если в приведенном ниже выражении x содержит значение null, исключение не будет сгенерировано:

x instanceof C

Выражение лишь вернет значение false. Это вполне разумно. Поскольку нулевая ссылка не относится ни к одному объекту, она, очевидно, не относится ни к одному объекту C.

Часто приведение типа объекта - не самое лучшее решение. В нашем примере выполнять преобразование объекта Employee в объект Manager не обязательно. Метод getSalary() прекрасно работает с обоими типами, поскольку динамическое связывание автоматически определяет правильный тип.

Приведение типов необходимо лишь тогда, когда надо использовать уникальный метод, который есть только в классе Manager, например setBonus(). Если вы, работая с объектом Employee, вдруг захотите вызвать метод setBonus(), задайте себе вопрос: не свидетельствует ли это о недостатках суперкласса? Возможно, следует пересмотреть структуру суперкласса и добавить в него метод setBonus(). Помните, чтобы программа завершила свою работу, достаточно одного-единственного необрабатываемого исключения ClassCastException. В общем, следует по возможности избегать приведения типов и применения оператора instanceof.

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

Manager boss = (Manager) staff[1];                 // Java

Manager* boss = dynamic_cast<Manager*>(staff[1]);  // C++

Между ними существует одно различие. Если приведение типов выполняется неверно, оно не порождает нулевой объект, а генерирует исключение. В этом смысле приведение типов в Java напоминает приведение ссылочных типов - references в C++. Это очень значительный недостаток. В C++ и контроль, и преобразование типов можно выполнить в одном операторе:

Manager* boss = dynamic_cast<Manager*>(staff[1]);  // C++

if (boss != NULL) ...

В Java используется комбинация оператора instanceof и оператора приведения типов:

if (staff[1] instanceof Manager) {  // Java
    Manager boss = (Manager) staff[1];
    ...
}


Абстрактные классы

Поднимаясь по иерархии наследования, классы становятся более универсальными и более абстрактными. В некотором смысле родительские классы, находящиеся в верхней части иерархии, становятся настолько абстрактными, что их рассматривают как основу для разработки других классов, а не как класс, позволяющий создавать конкретные объекты. Расширим, например, иерархию, в которую входит класс Employee. Сотрудник - это человек, а человек может быть студентом. Расширим нашу иерархию классов, добавив в нее классы Person и Student.

Person.png

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

Добавим теперь новый метод getDescription(), предназначенный для создания краткой характеристики человека, например:

an employee with a salary of $50,000.00
a student majoring in computer science

Для классов Employee и Student этот метод реализуется довольно просто. Однако какую информацию можно поместить в класс Person? В нем ничего нет, кроме имени. Разумеется, можно было бы реализовать метод Person.getDescription(), возвращающий пустую строку. Однако есть способ получше. Используя ключевое слово abstract, можно вовсе не реализовывать этот метод:

public abstract String getDescription();  // реализация не требуется

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

abstract class Person {
    ...
    public abstract String getDescription();
}

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

abstract class Person {

    public Person(String n) {
        name = n;
    }
  
    public abstract String getDescription();
  
    public String getName() {
        return name;
    }
  
    private String name;
}

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

Абстрактные методы представляют собой прототипы методов, реализованных в подклассах. Расширяя абстрактный класс, можно оставить некоторые или все абстрактные методы неопределенными. При этом подкласс станет абстрактным. Кроме того, можно определить все методы. Тогда подкласс не будет абстрактным.

Определим класс Student, расширяющий абстрактный класс Person и реализующий метод getDescription(). Поскольку ни один из методов в классе Student не является абстрактным, нет никакой необходимости объявлять сам класс абстрактным.

Класс может быть объявлен абстрактным, даже если он не содержит ни одного абстрактного метода.

Создать объекты абстрактного класса невозможно. Например, приведенное ниже выражение ошибочно:

new Person("Vince Vu");

Однако можно создать объекты конкретного подкласса.

Заметим, что можно создавать объектные переменные абстрактных классов, однако такие переменные должны ссылаться на объект неабстрактного класса. Рассмотрим следующую строку кода:

Person p = new Student("Vince Vu", "Economics");

где p - пременная абстрактного типа Person, ссылающаяся на экземпляр неабстрактного подкласса Student.

В C++ абстрактный метод называется чисто виртуальной функцией - pure virtual function. Его обозначение оканчивается символами =0.

class Person {  // C++
    public:
        virtual string getDescription() = 0;
        ...
}

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

Определим конкретный подкласс Student, расширяющий аьстрактный класс Person.

class Student extends Person {

    public Student(String n, String m) {
        super(n);
        major = m;
    }
  
    public String getDescription() {
        return "a student majoring in " + major;
    }
  
    private String major;
}

В этом подклассе определяется метод getDescription(). Следовательно, все методы в классе Student являются конкретными, и класс больше не является абстрактным.

В листинге, представленном ниже, определен абстрактный суперкласс Person и два конкретных подкласса Employee и Student. Заполним массив типа Person ссылками на экземпляры классов Employee и Student.

Person[] people = new Person[2];

people[0] = new Employee( ... );

people[1] = new Student( ... );

Затем выведем имена и характеристики этих объектов.

for (Person p : people) {
    System.out.println(p.getName() + ", " + p.getDescription());
}

Некоторых читателей присутствие вызова p.getDescription() может озадачить. Не относится и он к неопределенному методу? Учтите, что переменная p никогда не сылается ни на один объект абстрактного класса Person, поскольку создать такой объект попросту невозможно. Переменная p всегда ссылается на объект конкретного подкласса, например Employee или Student. Для этих объектов метод getDescription() определен.

Можно ли пропустить в классе Person все абстрактные методы и определить метод getDescription() в подклассах Employee и Student? Это не будет ошибкой, но тогда метод getDescription() нельзя будет вызвать с помощью переменной p. Компилятор гарантирует, что вызываются только методы, определенные в классе.

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

Содержимое файла PersonTest.java

import java.util.*;

public class PersonTest {

    public static void main(String[] args) {
        Person[] people = new Person[2];

        // fill the people array with Student and Employee objects
        people[0] = new Employee("Harry Hacker", 50000, 1989, 10, 1);
        people[1] = new Student("Maria Morris", "computer science");

        // print out names and descriptions of all Person objects
        for (Person p : people) {
            System.out.println(p.getName() + ", " + p.getDescription());
        }
    }
}

abstract class Person {

    public Person(String n) {
        name = n;
    }

    public abstract String getDescription();

    public String getName() {
        return name;
    }

    private String name;
}

class Employee extends Person {

    public Employee(String n, double s, int year, int month, int day) {
        // Имя передается конструктору суперкласса.
        super(n);
        salary = s;
        GregorianCalendar calendar = new GregorianCalendar(year, month - 1, day);
        hireDay = calendar.getTime();
    }

    public double getSalary() {
        return salary;
    }

    public Date getHireDay() {
        return hireDay;
    }

    public String getDescription() {
        return String.format("an employee with a salary of $%.2f", salary);
    }

    public void raiseSalary(double byPercent) {
        double raise = salary * byPercent / 100;
        salary += raise;
    }

    private double salary;
    private Date hireDay;
}

class Student extends Person {

    /**
     * @param n the student's name
     * @param m the student's major
     */
    public Student(String n, String m) {
        // pass n to superclass constructor
        super(n);
        major = m;
    }

    public String getDescription() {
        return "a student majoring in " + major;
    }

    private String major;
}


Защищенный доступ

Как известно, поля в классе желательно объявлять как private, а некоторые методы - как public. Любые закрытые (private) элементы невидимы из других классов. Это утверждение справедливо и для подклассов: подкласс не имеет доступа к закрытым полям суперкласса.

Иногда нужно ограничивать доступ к некоторому методу и открывать его лишь для подклассов. Гораздо реже возникает необходимость предоставлять методам подкласса доступ к полям суперкласса. В этом случае элемент класса объявляется защищенным с помощью ключевого слова protected. Например, если в суперклассе Employee поле hireDay объявлено защищенным, а не закрытым, методы подкласса Manager смогут обращаться к нему непосредственно.

Однако методы класса Manager имеют доступ лишь к полям hireDay, принадлежащим объектам самого класса Manager, но не класса Employee. Это ограничение введено для того, чтобы программисты не могли злоупотреблять механизмом защищенного доступа и создавать подклассы лишь для получения доступа к защищенным полям.

На практике пользоваться атрибутом protected нужно очень осторожно. Предположим, что ваш класс, в котором есть защищенные поля, используется другими разработчиками. Без вашего ведома другие программисты могут создавать подклассы вашего класса и тем самым получать доступ к защищенным полям. Теперь вы уже не можете изменять реализацию своего класса, не уведомив других программистов. Это противоречит принципам ООП, поощряющего инкапсуляцию данных.

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

В качестве примера защищенного метода можно привести clone() класса Object.

В Java защищенные элементы доступны из всех подклассов, а также из других классов того же пакета. Этим Java отличается от C++, в котором ключевое слово protected имеет несколько иной смысл. Таким образом, в Java ограничения на доступ к защищенным элементам шире, чем в C++.

Итак, в Java есть четыре модификатора доступа, управляющих областью видимости:

  • Область видимости ограничена классом (private).
  • Область видимости не ограничена (public).
  • Область видимости ограничена пакетом и всеми подклассами (protected).
  • Область видимости ограничена пакетом (к сожалению, по умолчанию). Никакого модификатора указывать не нужно.



Форум

Назад | Оглавление | Дальше

Personal tools
Namespaces

Variants
Actions
Navigation
Tools