Модификаторы private и protected, переопределение методов, полиморфизм

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

class Properties {
    private int id;
    int width, color;
 
    Properties() {
        System.out.println("Конструктор Properties");
    }
}
 
class Line extends Properties {
    double x1, y1;
    double x2, y2;
 
    Line() {
        System.out.println("Конструктор Line");
    }
}

Теперь, обращаясь к полю id из дочернего класса:

class Line extends Properties {
…
    Line() {
        System.out.println("Конструктор Line, id = " + id);
    }
}

возникнет ошибка. То же самое и с методами. Если в базовом классе определить какой-либо метод, например, сеттер:

class Properties {
…
    void setProp(int width, int color) {
        this.width = width;
        this.color = color;
    }}

то он автоматически унаследуется дочерним. Но, если указать модификатор private:

private void setProp(int width, int color) {}

то он будет недоступен в дочернем классе Line. Все остальные режимы доступа разрешают наследование элементов класса. Вот так можно управлять механизмом наследования с помощью модификатора доступа private.

Режим доступа protected

Но здесь возникает вопрос: а как задавать поля или методы, которые бы могли наследоваться, но к которым нельзя было бы обратиться извне? Как мы видели, режим private для этого не подходит – он запрещает наследование элементов. Здесь нам на помощь приходит другой режим доступа под названием protected. Он разрешает наследование элементов, но закрывает внешний доступ к полям и методам, если класс определен в другом пакете. В текущем пакете внешний доступ к полю или методу protected будет разрешен. Например, если пометить поле id как protected, то оно унаследуется дочерним классом Line:

class Properties {
    protected int id;
...
}
 
class Line extends Properties {
    double x1, y1;
    double x2, y2;
 
    Line() {
        id = 1;
        System.out.println("Конструктор Line с id = "+id);
    }
}

Теперь, при запуске программы мы увидим значение id = 1. При этом, извне мы также можем обратиться к этому полю, так как находимся в текущем пакете:

line.id = 5;   // ошибки нет

Но, если переместить определение класса Line в другой пакет, то прямого доступа к id уже не будет и строчка:

line.id = 5;   // будет ошибка

выдаст ошибку. Вот так (в отличие от некоторых других языков программирования) работает модификатор protected в Java.

Переопределение методов и динамическая диспетчеризация

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

class Properties {
...
    void draw() {
        System.out.println("Этот метод следует переопределить");
    }
...
}

Если теперь его вызвать из объекта класса Line:

line.draw();

то увидим ожидаемое сообщение. Но, переопределив этот метод в дочернем классе Line:

    @Override
    void draw() {
        System.out.println("Рисование линии");
    }

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

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

Properties p = line;
p.draw();

то вызовется не метод базового класса, а переопределенный метод дочернего класса. Такой эффект возникает благодаря работе механизма под названием «динамическая диспетчеризация методов». Логика его работы проста: если он видит, что метод базового класса имеет переопределение в дочернем, то вызов переходит именно к последнему, переопределенному методу:

Здесь в обоих случаях будет вызван метод дочернего класса Line, т.к. он имеет последнее переопределение метода draw() базовых классов.

Полиморфизм

В частности, благодаря динамической диспетчеризации реализуется полиморфизм классов в Java. Здесь единый интерфейс draw() может иметь множество реализаций через переопределения в дочерних классах. В результате, можно выполнять рисование графических примитивов через ссылки Properties на базовый класс:

        final int N = 2;
        Properties p[] = new Properties[N];
        p[0] = new Line();
        p[1] = new Triangle();
 
        for(int i = 0;i < N;++i)
            p[i].draw();

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

Запрет наследования через final

В ряде случаев нам может потребоваться запретить наследование классов и переопределение методов. Например, определим сеттер setProp() в базовом классе. И мы бы не хотели, чтобы программист намеренно или случайно мог сделать переопределение этого метода. Модификатор private в этом случае нам не подойдет, т.к. предполагается доступ к сеттеру извне класса. Как же поступить? Здесь на помощь нам приходит ключевое слово final, которое не ограничивает режим доступа, но запрещает дальнейшее изменение метода. То есть, если определить сеттер вот так:

class Properties {
...
    final void setProp(int width, int color) {
        this.width = width;
        this.color = color;
    }
...
}

то переопределить его в дочерних классах уже будет невозможно и запись:

class Line extends Properties {
...
    void setProp(int width, int color) {
    }
...
}

приведет к ошибке. Или же мы можем запретить наследование всего класса, указав перед его определением это ключевое слово:

final class Properties { ... }

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

class Properties {
    final int id = 5;
...
}
 
class Line extends Properties {
    int id = 5;
...
}

Все работает. Разумеется, переменные id в обоих классах – это совершенно разные переменные.

Вот так с помощью ключевого слова final можно управлять механизмом наследования.

Путь кодера

Подвиг 1. Запишите базовый класс Graph для представления графиков с полями: массив из N значений (значения графика), название. И производные от него классы: LineGraph (для линейного графика), Chart (для круговой диаграммы), Bar (для столбикового графика). В дочерних классах следует реализовать перегрузку метода draw() базового класса Graph для рисования графика в соответствующем виде (рисование – это вывод в консоль сообщения, что рисуется такой-то график с такими-то значениями). Создать несколько экземпляров дочерних классов со ссылками на них типа Graph. Через эти ссылки вызвать метод draw() и убедиться в работоспособности механизма динамической диспетчеризации (вызовов методов из дочерних классов).

Подвиг 2. Реализовать класс Lib с запретом его наследования для работы с набором книг. Книги должны представлять другим классом Book с полями: название, автор, год издания, цена. В классе Lib реализовать следующий функционал:

  • добавление/удаление книги из библиотеки;
  • вывод информации по всем книгам;
  • поиск книг по автору, а также по году издания.

Подвиг 3. Создайте базовый класс Nota для описания музыкальных нот с полями: идентификатор, название, длительность, наличие диеза, наличие бемоля. Все поля должны быть закрыты и не наследоваться в дочерних классах. Работа с ними должна осуществляться через сеттеры и геттеры, которые запретить для переопределения в дочерних классах. Добавить определения дочерних классов для нот: до, ре, ми, фа, соль, ля, си. Создать их экземпляры и вывести информацию по ним в консоль.

Видео по теме