1. Концепции ООП

Объектно-ориентированное программирование - это парадигма, которая предоставляет множество концепций, таких как наследование, полиморфизм и т.д.

Simula считается первым объектно-ориентированным языком программирования.

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

Smalltalk считается первым истинным объектно-ориентированным языком программирования.

Популярными объектно-ориентированными языками являются:

  • Java

  • C#

  • PHP

  • Python

  • C++

1.1. ООП

Объект представляет собой реальную сущность из реального мира, например: BMW X5, Boeing 737, Parker Jotter (ручка).

Объектно-ориентированное программирование - это методология или парадигма для разработки программы с использованием классов и объектов. Это упрощает разработку и обслуживание программного обеспечения, предоставляя некоторые концепции:

  • Объект (Object)

  • Класс (Class)

  • Наследование (Inheritance)

  • Полиморфизм (Polymorphism)

  • Абстракция (Abstraction)

  • Инкапсуляция (Encapsulation)

Java OOPs Concepts

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

  • Связность (Coupling), Единство (Cohesion)

  • Ассоциация (Association)

  • Агрегация (Aggregation), Композиция (Composition)

1.1.1. Object

Любая сущность, которая имеет состояние (state) и поведение (behavior), называется объектом. Например: овчарка, Xiaomi Mi Notebook Pro 15 2021 Ryzen Edition, Samsung S22+. Это может быть как физический предмет, так и нет.

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

Пример: собака - это объект, потому что она имеет такие состояния, как цвет, имя, порода, а также поведение, такое как вилять хвостом, лаять, есть.

1.1.2. Class

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

1.1.3. Inheritance

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

Наследование обеспечивает повторное использование кода.

Наследование используется для достижения полиморфизма во время выполнения.

1.1.4. Polymorphism

Если одна задача выполняется разными способами, это называется полиморфизмом.

В Java используется перегрузка и переопределение методов для достижения полиморфизма.

Например, когда вызвать метод говорить, то: кошка говорит мяу, собака лает гав.

1.1.5. Abstraction

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

В Java используется абстрактный класс и интерфейс для достижения абстракции.

1.1.6. Encapsulation

Связывание (или упаковка) кода и данных в единый блок называется инкапсуляцией. Например, капсула, имеет одну оболочку, которая содержит различные лекарства.

Java Class является примером инкапсуляции.

Java Bean является полностью инкапсулированным классом, потому что все члены данных здесь являются закрытыми.

1.1.7. Coupling

Связность относится к знаниям/информации/зависимости одного класса о другом классе. Если у класса есть подробная информация о другом классе, существует strong coupling.

В Java используются private, protected, public модификаторы для отображения уровня видимости класса, метода и поля.

Можно использовать интерфейсы для weak coupling, потому что нет конкретной реализации.

1.1.8. Cohesion

Cohesion (сплоченность) относится к уровню компонента, который выполняет одну четко определенную задачу. Одна четко определенная задача выполняется highly cohesive методом. Weakly cohesive метод разделит задачу на отдельные части.

Например: пакет java.io представляет собой highly cohesive пакет, поскольку он имеет связанные с вводом/выводом классы и интерфейс. Тем не менее пакет java.util является weakly cohesive пакетом, потому что он имеет несвязанные классы и интерфейсы.

1.1.9. Association

Ассоциация представляет отношения между объектами. Здесь один объект может быть связан с одним или несколькими объектами. Между объектами может быть четыре типа связи:

  • One to One

  • One to Many

  • Many to One

  • Many to Many

Например, одна страна может иметь одного президента (One to One), а президент может иметь много министров (One to Many). Кроме того, у многих членов парламента может быть один президент (Many to One), а у многих министров может быть много департаментов (Many to Many).

Ассоциация может быть:

  • undirectional

  • bidirectional

Ассоциация достигается с помощью:

  • Inheritance

  • Aggregation

  • Composition

Aggregation

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

Агрегация представляет weak relationship между объектами.

Агрегация также называется связью has-a в Java. Мол, наследование представляет собой отношения is-a.

Агрегация еще один способ повторного использования объектов.

Composition

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

Композиция также является способом достижения ассоциации.

Существует strong relationship между содержащим объектом и зависимым объектом. Это состояние, в котором содержащиеся объекты не имеют самостоятельного существования. Если вы удалите родительский объект, все дочерние объекты будут удалены автоматически.

1.2. Преимущество ООП над процедурно-ориентированным языком программирования

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

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

  3. ООП дает возможность имитировать события в реальном мире гораздо более эффективно. Мы можем обеспечить решение проблемы с реальными словами, если мы используем язык объектно-ориентированного программирования.

1.3. В чем разница между object-oriented языком программирования и object-based языком программирования?

Object-based язык программирования следует всем функциям ООП, кроме наследования. JavaScript и VBScript являются примерами object-based языков программирования.

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

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

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

Класс определяется с помощью ключевого слова сlass:

class Person {
    // тело класса
}

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

Вся функциональность класса представлена:

  • членами класса - полями (fields - переменные класса), которые хранят состояние объекта

  • методами (methods), которые определяют поведение объекта. Например, класс Person, который представляет человека, мог бы иметь следующее определение:

class Person {
    String name; // имя
    int age; // возраст

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

В классе Person определены два поля: name представляет имя человека, а age - его возраст. И также определен метод displayInfo(), который ничего не возвращает и просто выводит эти данные на консоль.

Теперь используем данный класс. Для этого определим следующую программу:

public class Program {
    public static void main(String[] args) {
        Person tom;
    }
}

class Person {
    String name; // имя
    int age; // возраст

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

Как правило, классы определяются в разных файлах. В данном случае для простоты мы определяем два класса в одном файле. Стоит отметить, что в этом случае только один класс может иметь модификатор public (в данном случае это класс Program), а сам файл кода должен называться по имени этого класса, то есть в данном случае файл должен называться Program.java.

Класс представляет новый тип, поэтому мы можем определять переменные, которые представляют данный тип. Так, здесь в методе main() определена переменная tom, которая представляет класс Person. Но пока эта переменная не указывает ни на какой объект и по умолчанию она имеет значение null. По большому счету мы ее пока не можем использовать, поэтому вначале необходимо создать объект класса Person.

2.1. Конструкторы

Кроме обычных методов классы могут определять специальные методы, которые называются конструкторами (constructors). Конструкторы вызываются при создании нового объекта данного класса. Конструкторы выполняют инициализацию объекта.

Если в классе не определено ни одного конструктора, то для этого класса автоматически создается конструктор без параметров (default constructor).

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

public class Program {
    public static void main(String[] args) {
        Person tom = new Person(); // создание объекта
        tom.displayInfo();

        tom.name = "Tom"; // изменение имени
        tom.age = 34; // изменение возраста
        tom.displayInfo();
    }
}

class Person {
    String name; // имя
    int age; // возраст

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

Для создания объекта Person используется выражение new Person(). Оператор new выделяет память для объекта Person. И затем вызывается конструктор по умолчанию, который не принимает никаких параметров. В итоге после выполнения данного выражения в памяти будет выделен участок, где будут храниться все данные объекта Person. А переменная tom получит ссылку на созданный объект.

Если конструктор не инициализирует значения переменных объекта, то они получают значения по умолчанию. Для переменных числовых типов это число 0, а для типа String и других классов - это значение null (то есть фактически отсутствие значения).

После создания объекта мы можем обратиться к переменным объекта Person через переменную tom и установить или получить их значения, например, tom.name = "Tom".

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

public class Program {
    public static void main(String[] args) {
        Person bob = new Person(); // вызов первого конструктора без параметров
        bob.displayInfo();

        Person tom = new Person("Tom"); // вызов второго конструктора с одним параметром
        tom.displayInfo();

        Person sam = new Person("Sam", 25); // вызов третьего конструктора с двумя параметрами
        sam.displayInfo();
    }
}

class Person {
    String name; // имя
    int age; // возраст

    Person() {
        name = "Undefined";
        age = 18;
    }

    Person(String n) {
        name = n;
        age = 18;
    }

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

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

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

2.2. Ключевое слово this

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

public class Program {
    public static void main(String[] args) {
        Person undef = new Person();
        undef.displayInfo();

        Person tom = new Person("Tom");
        tom.displayInfo();

        Person sam = new Person("Sam", 25);
        sam.displayInfo();
    }
}

class Person {
    String name; // имя
    int age; // возраст

    Person() {
        this("Undefined", 18);
    }

    Person(String name) {
        this(name, 18);
    }

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

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

this.name = name;

Так, в данном случае указываем, что значение параметра name присваивается полю name.

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

Person(String name) {
    this(name, 18);
}

В итоге результат программы будет тот же, что и в предыдущем примере.

2.3. Инициализаторы

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

public class Program {
    public static void main(String[] args) {
        Person undef = new Person();
        undef.displayInfo();

        Person tom = new Person("Tom");
        tom.displayInfo();
    }
}

class Person {
    String name; // имя
    int age; // возраст

    /*начало блока инициализатора*/
    {
        name = "Undefined";
        age = 18;
    }
    /*конец блока инициализатора*/

    Person() {
    }

    Person(String name) {
        this.name = name;
    }

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void displayInfo() {
        System.out.printf("Name: %s \tAge: %d\n", name, age);
    }
}

3. Объекты как параметры методов

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

public class Program {
    public static void main(String[] args) {
        Person kate = new Person("Kate");
        System.out.println(kate.getName()); // "Kate"
        changeName(kate);
        System.out.println(kate.getName()); // "Alice"
    }

    static void changeName(Person p) {
        p.setName("Alice");
    }
}

class Person {
    private String name;

    Person(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}

Здесь в метод changeName передается объект Person, у которого изменяется имя. Так как в метод будет передаваться копия ссылки на область памяти, в которой находится объект Person, то переменная kate и параметр p метода changeName() будут указывать на один и тот же объект в памяти. Поэтому после выполнения метода у объекта kate, который передается в метод, будет изменено имя с "Kate" на "Alice".

От этого случая следует отличать другой случай:

public class Program {
    public static void main(String[] args) {
        Person kate = new Person("Kate");
        System.out.println(kate.getName()); // "Kate"
        changePerson(kate);
        System.out.println(kate.getName()); // "Kate" - изменения не произошло, т.к. 'kate' хранит ссылку на старый объект
    }

    static void changePerson(Person p) {
        p = new Person("Alice"); // 'p' указывает на новый объект
        p.setName("Ann");
    }

    static void changeName(Person p) {
        p.setName("Alice");
    }
}

class Person {
    private String name;

    Person(String name) {
        this.name = name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }
}

В метод changePerson() также передается копия ссылки на объект Person. Однако в самом методе мы изменяем не отдельные значения объекта, а пересоздаем объект с помощью конструктора и оператора new. В результате в памяти будет выделено новое место для нового объекта Person, и ссылка на этот объект будет присвоена параметру p:

static void changePerson(Person p) {
    p = new Person("Alice"); // 'p' указывает на новый объект
    p.setName("Ann"); // изменяется новый объект
}

То есть после создания нового объекта Person параметр p и переменная kate в методе main() будут хранить ссылки на разные объекты. Переменная kate, которая передавалась в метод, продолжит хранить ссылку на старый объект в памяти. Поэтому ее значение не меняется.

4. Пакеты

Как правило, в Java классы объединяются в пакеты. Пакеты позволяют организовать классы логически в наборы. По умолчанию java уже имеет ряд встроенных пакетов, например, java.lang, java.util, java.io и т.д. Кроме того, пакеты могут иметь вложенные пакеты.

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

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

package название_пакета;

Как правило, названия пакетов соответствуют физической структуре проекта, то есть организации каталогов, в которых находятся файлы с исходным кодом. А путь к файлам внутри проекта соответствует названию пакета этих файлов. Например, если классы принадлежат пакету my.pack, то эти классы помещаются в проекте в папку my/pack.

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

Например, создадим в папке для исходных файлов директорию study. В нем создадим файл Program.java со следующим кодом:

package study;

public class Program {
    public static void main(String[] args) {
        Person kate = new Person("Kate", 32);
        kate.displayInfo();
    }
}

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    void displayInfo() {
        System.out.printf("Name: %s \t Age: %d \n", name, age);
    }
}

Директива package study в начале файла указывает, что классы Program и Person, которые здесь определены, принадлежат пакету study.

Когда мы работаем в среде разработке, то IDE берет на себя все вопросы компиляции пакетов и входящих в них файлов. Соответственно нам достаточно нажать на кнопку, и все будет готово. Однако если мы компилируем программу в командной строке, то мы можем столкнуться с некоторыми трудностями. Поэтому рассмотрим этот аспект.

4.1. Работа с пакетами в terminal

Для компиляции программы вначале в командной строке/терминале с помощью команды cd перейдем к папке, где находится каталог study.

cd C:\java

Например, в моем случае это каталог C:\java (то есть файл с исходным кодом расположен по пути C:\java\study\Program.java).

Для компиляции выполним команду

javac study\Program.java

После этого в папке study появятся скомпилированные файлы Program.class и Person.class. Для запуска программы выполним команду:

java study.Program

4.2. Импорт пакетов и классов

Если нам надо использовать классы из других пакетов, то нам надо подключить эти пакеты и классы. Исключение составляют классы из пакета java.lang (например, String), которые подключаются в программу автоматически.

Например, класс Scanner находится в пакете java.util, поэтому мы можем получить к нему доступ следующим способом:

java.util.Scanner in = new java.util.Scanner(System.in);

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

package study;

import java.util.Scanner; // импорт класса Scanner

public class Program {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
    }
}

Директива import указывается в самом начале кода, после чего идет имя подключаемого класса (в данном случае класса Scanner).

В примере выше мы подключили только один класс, однако пакет java.util содержит еще множество классов. И чтобы не подключать по отдельности каждый класс, мы можем сразу подключить весь пакет:

import java.util.*; // импорт всех классов из пакета java.util

Теперь мы можем использовать любой класс из пакета java.util.

Возможна ситуация, когда мы используем два класса с одним и тем же названием из двух разных пакетов, например, класс Date имеется и в пакете java.util, и в пакете java.sql. И если нам надо одновременно использовать два этих класса, то необходимо указывать полный путь к этим классам в пакете:

java.util.Date utilDate = new java.util.Date();
java.sql.Date sqlDate = new java.sql.Date();

4.3. Статический импорт

В java есть также особая форма импорта - статический импорт. Для этого вместе с директивой import используется модификатор static:

package study;

import static java.lang.System.*;
import static java.lang.Math.*;

public class Program {
    public static void main(String[] args) {
        double result = sqrt(20);
        out.println(result);
    }
}

Здесь происходит статический импорт классов System и Math. Эти классы имеют статические методы. Благодаря операции статического импорта мы можем использовать эти методы без названия класса. Например, писать не Math.sqrt(20), а sqrt(20), так как функция sqrt(), которая возвращает квадратный корень числа, является статической.

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

5. Модификаторы доступа

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

В Java используются следующие модификаторы доступа:

  • public - публичный, общедоступный класс или член класса. Поля и методы, объявленные с модификатором public, видны другим классам из текущего пакета и из внешних пакетов.

  • private - закрытый класс или член класса, противоположность модификатору public. Закрытый класс или член класса доступен только из кода в том же классе.

  • protected - такой класс или член класса доступен из любого места в текущем классе или пакете или в производных классах, даже если они находятся в других пакетах

  • default (package): отсутствие модификатора у поля или метода класса предполагает применение к нему модификатора по умолчанию. Такие поля или методы видны всем классам в текущем пакете.

Рассмотрим модификаторы доступа на примере следующей программы:

public class Program {
    public static void main(String[] args) {
        Person kate = new Person("Kate", 32, "Baker Street", "+12334567");
        kate.displayName(); // норм, метод public
        kate.displayAge(); // норм, метод имеет модификатор по умолчанию
        kate.displayPhone(); // норм, метод protected
        kate.displayAddress(); // ! Ошибка, метод private

        System.out.println(kate.name); // норм, модификатор по умолчанию
        System.out.println(kate.address); // норм, модификатор public
        System.out.println(kate.age); // норм, модификатор protected
        System.out.println(kate.phone); // ! Ошибка, модификатор private
    }
}

class Person {
    String name;
    protected int age;
    public String address;
    private String phone;

    public Person(String name, int age, String address, String phone) {
        this.name = name;
        this.age = age;
        this.address = address;
        this.phone = phone;
    }

    public void displayName() {
        System.out.printf("Name: %s \n", name);
    }

    void displayAge() {
        System.out.printf("Age: %d \n", age);
    }

    private void displayAddress() {
        System.out.printf("Address: %s \n", address);
    }

    protected void displayPhone() {
        System.out.printf("Phone: %s \n", phone);
    }
}

В данном случае оба класса расположены в одном пакете - пакете по умолчанию, поэтому в классе Program мы можем использовать все методы и переменные класса Person, которые имеют модификатор по умолчанию, public и protected. А поля и методы с модификатором private в классе Program не будут доступны.

Если бы класс Program располагался бы в другом пакете, то ему были бы доступны только поля и методы с модификатором public.

Модификатор доступа должен предшествовать остальной части определения переменной или метода.

6. Инкапсуляция

Казалось бы, почему бы не объявить все переменные и методы с модификатором public, чтобы они были доступны в любой точке программы вне зависимости от пакета или класса?

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

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

Так, как правило, вместо непосредственного использования полей, как правило, используют методы доступа. Например:

public class Program {
    public static void main(String[] args) {
        Person kate = new Person("Kate", 30, true);
        System.out.println(kate.getAge()); // 30
        kate.setAge(33);
        System.out.println(kate.getAge()); // 33
        kate.setAge(123450);
        System.out.println(kate.getAge()); // 33
    }
}

class Person {
    private String name;
    private int age;
    private boolean adult;

    public Person(String name, int age, boolean adult) {
        this.name = name;
        this.age = age;
        this.adult = adult;
    }

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return this.age;
    }

    public void setAge(int age) {
        if (age > 0 && age < 110) {
            this.age = age;
        }
    }

    public boolean isAdult() {
        return this.adult;
    }

    public void setAdult(boolean adult) {
        this.adult = adult;
    }
}

И затем вместо непосредственной работы с полями name и age в классе Person мы будем работать с методами, которые устанавливает и возвращают значения этих полей. Методы setName(), setAge() и setAdult(), которые меняют состояние объекта, иногда называют модифицирующими (mutator). А методы getName(), getAge() и isAdult(), с помощью которых получаем доступ к состоянию класса, называют методы доступа (accessor).

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

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

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

class Person {
    private String name;
    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.println("Name: " + name);
    }
}

И, возможно, впоследствии мы захотим добавить еще один класс, который описывает сотрудника предприятия - класс Employee. Так как этот класс реализует тот же функционал, что и класс Person, так как сотрудник - это также и человек, то было бы рационально сделать класс Employee производным (subclass, наследником, подклассом) от класса Person, который, в свою очередь, называется базовым классом (superclass, родителем, суперклассом):

class Employee extends Person {
}

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

Использование классов:

public class Program {
    public static void main(String[] args) {
        Person tom = new Person("Tom");
        tom.display();
        Employee sam = new Employee("Sam");
        sam.display();
    }
}

class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.println("Name: " + name);
    }
}

class Employee extends Person {
}

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

public class Program {
    public static void main(String[] args) {
        Employee sam = new Employee("Sam", "Red Hat");
        sam.display(); // Sam
        sam.work(); // Sam works in Red Hat
    }
}

class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.println("Name: " + name);
    }
}

class Employee extends Person {
    private String company;

    public Employee(String name, String company) {
        super(name);
        this.company = company;
    }

    public void work() {
        System.out.printf("%s works in %s \n", getName(), company);
    }
}

В данном случае класс Employee добавляет поле company, которое хранит место работы сотрудника, а также метод work().

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

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

7.1. Переопределение методов

Производный класс может определять свои методы, а может переопределять методы, которые унаследованы от базового класса. Например, переопределим в классе Employee метод display():

public class Program {
    public static void main(String[] args) {
        Employee sam = new Employee("Sam", "Red Hat");
        sam.display();  // Sam
        // Works in Red Hat
    }
}

class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.println("Name: " + name);
    }
}

class Employee extends Person {
    private String company;

    public Employee(String name, String company) {
        super(name);
        this.company = company;
    }

    @Override
    public void display() {
        System.out.printf("Name: %s \n", getName());
        System.out.printf("Works in %s \n", company);
    }
}

Перед переопределяемым методом указывается аннотация @Override. Данная аннотация в принципе необязательна.

При переопределении метода он должен иметь уровень доступа не меньше, чем уровень доступа в базовом класса. Например, если в базовом классе метод имеет модификатор public, то и в производном классе метод должен иметь модификатор public.

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

class Employee extends Person {
    private String company;

    public Employee(String name, String company) {
        super(name);
        this.company = company;
    }

    @Override
    public void display() {
        super.display();
        System.out.printf("Works in %s \n", company);
    }
}

С помощью ключевого слова super мы также можем обратиться к реализации методов базового класса.

7.2. Запрет наследования

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

public final class Person {
}

Если бы класс Person был бы определен таким образом, то следующий код был бы ошибочным и не сработал, так как мы тем самым запретили наследование:

class Employee extends Person {
}

Кроме запрета наследования можно также запретить переопределение отдельных методов. Например, в примере выше переопределен метод displayInfo(), запретим его переопределение:

public class Person {
    public final void display() {
        System.out.println("Имя: " + name);
    }
}

В этом случае класс Employee не сможет переопределить метод display().

7.3. Динамическая диспетчеризация методов

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

Person sam = new Employee("Sam", "Oracle");

Так как Employee наследуется от Person, то объект Employee является в то же время и объектом Person. Грубо говоря, любой работник предприятия одновременно является человеком.

Однако несмотря на то, что переменная представляет объект Person, виртуальная машина видит, что в реальности она указывает на объект Employee. Поэтому при вызове методов у этого объектов будет вызывать та версия методов, которая определена в классе Employee, а не в Person. Например:

public class Program {
    public static void main(String[] args) {
        Person tom = new Person("Tom");
        tom.display();
        Person sam = new Employee("Sam", "Oracle");
        sam.display();
    }
}

class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.printf("Person %s \n", name);
    }
}

class Employee extends Person {
    private String company;

    public Employee(String name, String company) {
        super(name);
        this.company = company;
    }

    @Override
    public void display() {
        System.out.printf("Employee %s works in %s \n", super.getName(), company);
    }
}

При вызове переопределенного метода виртуального машина динамически находит и вызывает именно ту версию метода, которая определена в подклассе. Данный процесс еще называется динамическая диспетчеризация методов (dynamic method lookup, динамический поиск метода).

8. Иерархия наследования и преобразование типов

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

public class Program {
    public static void main(String[] args) {
        Person tom = new Person("Tom");
        tom.display();
        Person sam = new Employee("Sam", "Oracle");
        sam.display();
        Person bob = new Client("Bob", "DeutscheBank", 3000);
        bob.display();
    }
}

class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public void display() {
        System.out.printf("Person %s \n", name);
    }
}

class Employee extends Person {
    private String company;

    public Employee(String name, String company) {
        super(name);
        this.company = company;
    }

    public String getCompany() {
        return company;
    }

    public void display() {
        System.out.printf("Employee %s works in %s \n", super.getName(), company);
    }
}

class Client extends Person {
    private int sum; // Переменная для хранения суммы на счете
    private String bank;

    public Client(String name, String bank, int sum) {
        super(name);
        this.bank = bank;
        this.sum = sum;
    }

    public void display() {
        System.out.printf("Client %s has account in %s \n", super.getName(), bank);
    }

    public String getBank() {
        return bank;
    }

    public int getSum() {
        return sum;
    }
}

В этой иерархии классов можно проследить следующую цепь наследования: ObjectPersonEmployee|Client.

hierarhy classes

8.1. Преобразование типов в языке Java

Суперклассы обычно размещаются выше подклассов, поэтому на вершине наследования находится класс Object, а в самом низу Employee и Client.

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

Object tom = new Person("Tom");
Object sam = new Employee("Sam", "Oracle");
Object kate = new Client("Kate", "DeutscheBank", 2000);
Person bob = new Client("Bob", "DeutscheBank", 3000);
Person alice = new Employee("Alice", "Google");

Это так называемое восходящее преобразование (upcasting) от подкласса внизу к суперклассу вверху иерархии. Такое преобразование осуществляется автоматически.

Обратное не всегда верно. Например, объект Person не всегда является объектом Employee или Client. Поэтому нисходящее преобразование (downcasting) от суперкласса к подклассу автоматически не выполняется. В этом случае нам надо использовать операцию преобразования типов.

Object sam = new Employee("Sam", "Oracle");

// нисходящее преобразование от Object к типу Employee
Employee emp = (Employee) sam;
emp.display();
System.out.println(emp.getCompany());

В данном случае переменная sam приводится к типу Employee. И затем через объект emp мы можем обратиться к функционалу объекта Employee.

Мы можем преобразовать объект Employee по всей прямой линии наследования от Object к Employee.

Примеры нисходящих преобразований:

Object kate = new Client("Kate", "DeutscheBank", 2000);
((Person) kate).display();

Object sam = new Employee("Sam", "Oracle");
((Employee) sam).display();

Но рассмотрим еще одну ситуацию:

Object kate = new Client("Kate", "DeutscheBank", 2000);
Employee emp = (Employee) kate;
emp.display();

// или так
((Employee) kate).display();

В данном случае переменная типа Object хранит ссылку на объект Client. Мы можем без ошибок привести этот объект к типам Person или Client. Но при попытке преобразования к типу Employee мы получим ошибку во время выполнения. Так как kate не представляет объект типа Employee.

Здесь мы явно видим, что переменная kate - это ссылка на объект Client, а не Employee. Однако нередко данные приходят извне, и мы можем точно не знать, какой именно объект эти данные представляют. Соответственно возникает большая вероятная столкнуться с ошибкой. И перед тем, как провести преобразование типов, мы можем проверить, а можем ли мы выполнить приведение с помощью оператора instanceof:

Object kate = new Client("Kate", "DeutscheBank", 2000);
if (kate instanceof Employee) {
    ((Employee) kate).display();
} else {
    System.out.println("Conversion is invalid");
}

Выражение kate instanceof Employee проверяет, является ли переменная kate объектом типа Employee. Но так как в данном случае явно не является, то такая проверка вернет значение false, и преобразование не сработает.

9. Перегрузка методов

В программе мы можем использовать методы с одним и тем же именем, но с разными типами и/или количеством параметров. Такой механизм называется перегрузкой методов (method overloading).

Например:

public class Program {
    public static void main(String[] args) {
        System.out.println(sum(2, 3)); // 5
        System.out.println(sum(4.5, 3.2)); // 7.7
        System.out.println(sum(4, 3, 7)); // 14
    }

    static int sum(int x, int y) {
        return x + y;
    }

    static double sum(double x, double y) {
        return x + y;
    }

    static int sum(int x, int y, int z) {
        return x + y + z;
    }
}

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

Стоит отметить, что на перегрузку методов влияют количество и типы параметров. Однако различие в типе возвращаемого значения для перегрузки не имеют никакого значения. Например, в следующем случае методы различаются по типу возвращаемого значения:

public class Program {
    public static void main(String[] args) {
        System.out.println(sum(2, 3));
        System.out.println(sum(4, 3));
    }

    static int sum(int x, int y) {
        return x + y;
    }

    static double sum(int x, int y) {
        return x + y;
    }
}

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

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

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

При определении абстрактных классов используется ключевое слово abstract:

public abstract class Human {
    private String name;

    public String getName() {
        return name;
    }
}

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

Human h = new Human();

Кроме обычных методов абстрактный класс может содержать абстрактные методы. Такие методы определяются с помощью ключевого слова abstract и не имеют никакого функционала:

public abstract void display();

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

Зачем нужны абстрактные классы? Допустим, мы делаем программу для обслуживания банковских операций и определяем в ней три класса: Person, который описывает человека, Employee, который описывает банковского служащего, и класс Client, который представляет клиента банка. Очевидно, что классы Employee и Client будут производными от класса Person, так как оба класса имеют некоторые общие поля и методы. И так как все объекты будут представлять либо сотрудника, либо клиента банка, то напрямую мы от класса Person создавать объекты не будем. Поэтому имеет смысл сделать его абстрактным.

public class Program {
    public static void main(String[] args) {
        Employee sam = new Employee("Sam", "Leman Brothers");
        sam.display();
        Client bob = new Client("Bob", "Leman Brothers");
        bob.display();
    }
}

abstract class Person {
    private String name;

    public String getName() {
        return name;
    }

    public Person(String name) {
        this.name = name;
    }

    public abstract void display();
}

class Employee extends Person {
    private String bank;

    public Employee(String name, String company) {
        super(name);
        this.bank = company;
    }

    public void display() {
        System.out.printf("Employee Name: %s \t Bank: %s \n", super.getName(), bank);
    }
}

class Client extends Person {
    private String bank;

    public Client(String name, String company) {
        super(name);
        this.bank = company;
    }

    public void display() {
        System.out.printf("Client Name: %s \t Bank: %s \n", super.getName(), bank);
    }
}

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

abstract class Figure {
    float x; // x-координата точки
    float y; // y-координата точки

    Figure(float x, float y) {
        this.x = x;
        this.y = y;
    }

    public abstract float getPerimeter();

    public abstract float getArea();
}

class Rectangle extends Figure {
    private float width;
    private float height;

    Rectangle(float x, float y, float width, float height) {
        super(x, y);
        this.width = width;
        this.height = height;
    }

    public float getPerimeter() {
        return width * 2 + height * 2;
    }

    public float getArea() {
        return width * height;
    }
}

11. Интерфейсы

Механизм наследования очень удобен, но он имеет свои ограничения. В частности мы можем наследовать только от одного класса, в отличии, например, от языка С++, где имеется множественное наследование.

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

Чтобы определить интерфейс, используется ключевое слово interface. Например:

interface Printable {
    void print();
}

Данный интерфейс называется Printable. Интерфейс может определять константы и методы, которые могут иметь, а могут и не иметь реализации. Методы без реализации похожи на абстрактные методы абстрактных классов. Так, в данном случае объявлен один метод, который не имеет реализации.

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

Чтобы класс применил интерфейс, надо использовать ключевое слово implements:

public class Program {
    public static void main(String[] args) {
        Book b1 = new Book("Java. Complete Reference.", "H. Shildt");
        b1.print();
    }
}

interface Printable {
    void print();
}

class Book implements Printable {
    String name;
    String author;

    Book(String name, String author) {
        this.name = name;
        this.author = author;
    }

    public void print() {
        System.out.printf("%s (%s) \n", name, author);
    }
}

В данном случае класс Book реализует интерфейс Printable. При этом надо учитывать, что если класс применяет интерфейс, то он должен реализовать все методы интерфейса, как в случае выше реализован метод print(). Потом в методе main мы можем объект класса Book и вызвать его метод print(). Если класс не реализует какие-то методы интерфейса, то такой класс должен быть определен как абстрактный, а его неабстрактные классы-наследники затем должны будут реализовать эти методы.

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

Printable pr = new Printable();
pr.print();

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

class Journal implements Printable {
    private String name;

    String getName() {
        return name;
    }

    Journal(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println(name);
    }
}

Класс Book и класс Journal связаны тем, что они реализуют интерфейс Printable. Поэтому мы динамически в программе можем создавать объекты Printable как экземпляры обоих классов:

public class Program {
    public static void main(String[] args) {
        Printable printable = new Book("Java. Complete Reference", "H. Shildt");
        printable.print(); // Java. Complete Reference (H. Shildt)
        printable = new Journal("Foreign Policy");
        printable.print(); // Foreign Policy
    }
}

interface Printable {
    void print();
}

class Book implements Printable {
    String name;
    String author;

    Book(String name, String author) {
        this.name = name;
        this.author = author;
    }

    public void print() {
        System.out.printf("%s (%s) \n", name, author);
    }
}

class Journal implements Printable {
    private String name;

    String getName() {
        return name;
    }

    Journal(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println(name);
    }
}

11.1. Интерфейсы в преобразованиях типов

Все сказанное в отношении преобразования типов характерно и для интерфейсов. Например, так как класс Journal реализует интерфейс Printable, то переменная типа Printable может хранить ссылку на объект типа Journal:

Printable p = new Journal("Foreign Affairs");
p.print();
// Интерфейс не имеет метода getName, необходимо явное приведение
String name = ((Journal) p).getName();
System.out.println(name);

И если мы хотим обратиться к методам класса Journal, которые определены не в интерфейсе Printable, а в самом классе Journal, то нам надо явным образом выполнить преобразование типов: ((Journal) p).getName();

11.2. Методы по умолчанию

Ранее до JDK 8 при реализации интерфейса мы должны были обязательно реализовать все его методы в классе. А сам интерфейс мог содержать только определения методов без конкретной реализации. В JDK 8 была добавлена такая функциональность как методы по умолчанию. И теперь интерфейсы кроме определения методов могут иметь их реализацию по умолчанию, которая используется, если класс, реализующий данный интерфейс, не реализует метод. Например, создадим метод по умолчанию в интерфейсе Printable:

interface Printable {
    default void print() {
        System.out.println("Undefined printable");
    }
}

Метод по умолчанию - это обычный метод без модификаторов, который помечается ключевым словом default. Затем в классе Journal нам необязательно этот метод реализовать, хотя мы можем его и переопределить:

class Journal implements Printable {
    private String name;

    String getName() {
        return name;
    }

    Journal(String name) {
        this.name = name;
    }
}

11.3. Статические методы

Начиная с JDK 8 в интерфейсах доступны статические методы - они аналогичны методам класса:

interface Printable {
    void print();

    static void read() {
        System.out.println("Read printable");
    }
}

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

public static void main(String[] args) {
    Printable.read();
}

11.4. Приватные методы

По умолчанию все методы в интерфейсе фактически имеют модификатор public. Однако начиная с Java 9 мы также можем определять в интерфейсе методы с модификатором private. Они могут быть статическими и нестатическими, но они не могут иметь реализации по умолчанию.

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

public class Program {
    public static void main(String[] args) {
        Calculatable c = new Calculation();
        System.out.println(c.sum(1, 2));
        System.out.println(c.sum(1, 2, 4));
    }
}

class Calculation implements Calculatable {
}

interface Calculatable {
    default int sum(int a, int b) {
        return sumAll(a, b);
    }

    default int sum(int a, int b, int c) {
        return sumAll(a, b, c);
    }

    private int sumAll(int... values) {
        int result = 0;
        for (int n : values) {
            result += n;
        }
        return result;
    }
}

11.5. Константы в интерфейсах

Кроме методов в интерфейсах могут быть определены статические константы:

interface Stateable {
    int OPEN = 1;
    int CLOSED = 0;

    void printState(int n);
}

Хотя такие константы также не имеют модификаторов, но по умолчанию они имеют модификатор доступа public static final, и поэтому их значение доступно из любого места программы.

Применение констант:

public class Program {
    public static void main(String[] args) {
        WaterPipe pipe = new WaterPipe();
        pipe.printState(1);
    }
}

class WaterPipe implements Stateable {
    public void printState(int n) {
        if (n == OPEN) {
            System.out.println("Water is opened");
        } else if (n == CLOSED) {
            System.out.println("Water is closed");
        } else {
            System.out.println("State is invalid");
        }
    }
}

interface Stateable {
    int OPEN = 1;
    int CLOSED = 0;

    void printState(int n);
}

11.6. Множественная реализация интерфейсов

Если нам надо применить в классе несколько интерфейсов, то они все перечисляются через запятую после слова implements:

interface Printable {
    // методы интерфейса
}

interface Searchable {
    // методы интерфейса
}

class Book implements Printable, Searchable{
    // реализация класса
}

11.7. Наследование интерфейсов

Интерфейсы, как и классы, могут наследоваться:

interface BookPrintable extends Printable {
    void paint();
}

При применении этого интерфейса класс Book должен будет реализовать как методы интерфейса BookPrintable, так и методы базового интерфейса Printable.

11.8. Вложенные интерфейсы

Как и классы, интерфейсы могут быть вложенными, то есть могут быть определены в классах или других интерфейсах. Например:

class Printer {
    interface Printable {
        void print();
    }
}

При применении такого интерфейса нам надо указывать его полное имя вместе с именем класса:

public class Journal implements Printer.Printable {
    String name;

    Journal(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println(name);
    }
}

Использование интерфейса будет аналогично предыдущим случаям:

Printer.Printable p = new Journal("Foreign Affairs");
p.print();

11.9. Интерфейсы как параметры и результаты методов

И так же как и в случае с классами, интерфейсы могут использоваться в качестве типа параметров метода или в качестве возвращаемого типа:

public class Program {
    public static void main(String[] args) {
        Printable printable = createPrintable("Foreign Affairs", false);
        printable.print();

        read(new Book("Java for inpatients", "Cay Horstmann"));
        read(new Journal("Java Daily News"));
    }

    static void read(Printable p) {
        p.print();
    }

    static Printable createPrintable(String name, boolean option) {
        if (option) {
            return new Book(name, "Undefined");
        } else {
            return new Journal(name);
        }
    }
}

interface Printable {
    void print();
}

class Book implements Printable {
    String name;
    String author;

    Book(String name, String author) {
        this.name = name;
        this.author = author;
    }

    public void print() {
        System.out.printf("%s (%s) \n", name, author);
    }
}

class Journal implements Printable {
    private String name;

    String getName() {
        return name;
    }

    Journal(String name) {
        this.name = name;
    }

    public void print() {
        System.out.println(name);
    }
}

Метод read() в качестве параметра принимает объект интерфейса Printable, поэтому в этот метод мы можем передать как объект Book, так и объект Journal.

Метод createPrintable() возвращает объект Printable, поэтому также можно возвратить как объект Book, так и Journal.

12. Статические члены и модификатор static

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

public static void main(String[] args) {
}

Для объявления статических:

  • полей

  • констант

  • методов

  • инициализаторов

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

12.1. Статические поля

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

Например, создадим статическую переменную:

public class Program {
    public static void main(String[] args) {
        Person tom = new Person();
        Person bob = new Person();

        tom.displayId(); // "Id: 1"
        bob.displayId(); // "Id: 2"
        System.out.println(Person.counter); // 3

        Person.counter = 8; // изменяем Person.counter

        Person sam = new Person();
        sam.displayId(); // "Id: 8"
    }
}

class Person {
    private int id;
    static int counter = 1;

    Person() {
        id = counter++;
    }

    public void displayId() {
        System.out.printf("Id: %d\n", id);
    }
}

Класс Person содержит статическую переменную counter, которая увеличивается в конструкторе и ее значение присваивается переменной id. То есть при создании каждого нового объекта Person эта переменная будет увеличиваться, поэтому у каждого нового объекта Person значение поля id будет на 1 больше чем у предыдущего.

Так как переменная counter статическая, то мы можем обратиться к ней в программе по имени класса:

System.out.println(Person.counter); // получаем значение
Person.counter = 8; // изменяем значение

12.2. Статические константы

Также статическими бывают константы, которые являются общими для всего класса.

public class Program {
    public static void main(String[] args) {
        double radius = 60;
        System.out.printf("Radius: %f\n", radius); // "Radius: 60"
        System.out.printf("Area: %f\n", Math.PI * radius); // "Area: 11304,0"
    }
}

class Math {
    public static final double PI = 3.14;
}

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

System.out.println("hello");

out как раз представляет статическую константу класса System. Поэтому обращение к ней идет без создания объекта класса System.

12.3. Статические инициализаторы

Статические инициализаторы предназначены для инициализации статических переменных, либо для выполнения таких действий, которые выполняются при создании самого первого объекта. Например, определим статический инициализатор:

public class Program {
    public static void main(String[] args) {
        Person tom = new Person();
        Person bob = new Person();

        tom.displayId(); // "Id: 105"
        bob.displayId(); // "Id: 106"
    }
}

class Person {
    private int id;
    static int counter;

    static {
        counter = 105;
        System.out.println("Static initializer");
    }

    Person() {
        id = counter++;
        System.out.println("Constructor");
    }

    public void displayId() {
        System.out.printf("Id: %d\n", id);
    }
}

Статический инициализатор определяется как обычный, только перед ним ставится ключевое слово static. В данном случае в статическом инициализаторе мы устанавливаем начальное значение статического поля counter и выводим на консоль сообщение.

В самой программе создаются два объекта класса Person.

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

12.4. Статические методы

Статические методы также относятся ко всему классу в целом. Например, в примере выше статическая переменная counter была доступна извне, и мы могли изменить ее значение вне класса Person. Сделаем ее недоступной для изменения извне, но доступной для чтения. Для этого используем статический метод:

public class Program {
    public static void main(String[] args) {
        Person.displayCounter(); // "Counter: 1"

        Person tom = new Person();
        Person bob = new Person();

        Person.displayCounter(); // "Counter: 3"
    }
}

class Person {
    private int id;
    private static int counter = 1;

    Person() {
        id = counter++;
    }

    public static void displayCounter() {
        System.out.printf("Counter: %d\n", counter);
    }

    public void displayId() {
        System.out.printf("Id: %d\n", id);
    }
}

Теперь статическая переменная недоступна извне, она приватная. А ее значение выводится с помощью статического метода displayCounter(). Для обращения к статическому методу используется имя класса:

Person.displayCounter();

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

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

public class Program {
    public static void main(String[] args) {
        System.out.println(Operation.sum(45, 23)); // 68
        System.out.println(Operation.subtract(45, 23)); // 22
        System.out.println(Operation.multiply(4, 23)); // 92
    }
}

class Operation {
    static int sum(int x, int y) {
        return x + y;
    }

    static int subtract(int x, int y) {
        return x - y;
    }

    static int multiply(int x, int y) {
        return x * y;
    }
}

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

13. Default и Static методы в интерфейсах

Начиная с Java 8 появилась возможность добавлять неабстрактные реализации методов в интерфейсах с помощью ключевых слов default и static.

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

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

13.1. Default методы в интерфейсах

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

Другими словами, default методы - это методы, которые имеют реализацию.

Для того чтобы создать в интерфейсе default метод необходимо использовать ключевое слово default:

interface A {
    default void showDefault() {
        System.out.println("default show()");
    }
}
class B implements A {
    public void showDefault() {
        A.super.showDefault();
    }
}

Что аналогично:

public class B implements A {
}
public class Main {
    public static void main(String[] args) {
        A obj = new B();
        obj.showDefault();
    }
}
Output
default show()

13.1.1. Возможности при работе с default методами

При наследовании (расширении) интерфейса, который содержит default метод можно выполнить следующие действия:

  • default метод можно не указывать, так как он наследуется по умолчанию

interface A {
    default void showDefault() {
        System.out.println("A: default method show()");
    }
}
interface B extends A {
}
  • default метод можно сделать абстрактным

interface A {
    default void showDefault() {
        System.out.println("A: default method show()");
    }
}
interface B extends A {
    void showDefault();
}
  • default метод можно переопределить. В этом случае класс, который реализует интерфейс с default методом будет использовать реализацию, заданную в этом интерфейсе.

interface A {
    default void showDefault() {
        System.out.println("A: default method show()");
    }
}
interface B extends A {
    default void showDefault() {
        System.out.println("B: default method show()");
    }
}

13.1.2. Особые случаи

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

interface A {
    default void showDefault() {
        System.out.println("A: default method show()");
    }
}
interface B {
    default void showDefault() {
        System.out.println("B: default method show()");
    }
}
public class C implements A, B {
    public void showDefault() {
        System.out.println("C: override method show()");
    }
}

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

interface A {
    default void showDefault() {
        System.out.println("A: default method show()");
    }
}
public class B {
    public void showDefault() {
        System.out.println("B: method show()");
    }
}
public class C extends B implements A {
}

13.2. Static методы в интерфейсах

В дополнение к default методом можно определять static методы в интерфейсах.

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

Static методы в интерфейсе используются для обеспечения вспомогательных методов, например:

  • проверки на null

  • сортировки коллекций

  • и т.д.

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

Для того чтобы создать в интерфейсе static метод необходимо использовать ключевое слово static:

interface A {
    static void show() {
        System.out.println("static show()");
    }
}
class B {
    public void showPaper() {
        A.show();
    }
}

14. Вложенные классы

Вложенные классы (nested class) - это классы определенные внутри другого класса. Область действия вложенного класса ограничена областью действия внешнего класса. Если класс В определен в классе А, то класс B не может существовать независимо от класса А. Вложенный класс имеет доступ к членам (в том числе private) того класса, в который он объявлен.

Типы вложенных классов:

  • Обычный внутренний класс (regular inner class or member class).

  • Локальный класс (method-local inner class).

  • Анонимный класс (anonymous inner class).

  • Статический вложенный класс (static nested class).

Nested Classes

14.1. Обычный внутренний класс

Внутренний класс (inner class) определяется в области действия внешнего класса.

Чтобы создать объект внутреннего класса, должен существовать объект внешнего класса.

Внутренний и внешний класс имеют доступ ко всем членам класса друг друга (даже private).

Следующий пример демонстрирует объявление обычного внутреннего класса:

public class Town {
    private String postCode = "33333";

    public class Street {
        private int house;

        public void printAddress() {
            System.out.println("Город " + Town.this);
            System.out.println("Индекс " + postCode);
            System.out.println("Улица " + this);
            System.out.println("Дом " + house);
        }
    }

    public void createStreet() {
        Street street = new Street();
        street.house = 78;
        street.printAddress();
    }

    public static void main(String[] args) {
        Town town = new Town();
        town.createStreet();
        Town.Street street1 = town.new Street();
        Town.Street street2 = new Town().new Street();
        street1.printAddress();
        street2.printAddress();
    }
}

Внутри метода внешнего класса, объект внутреннего класса создается как обычно:

Street street = new Street();

Если мы создаем объект внутреннего класса не в методах внешнего класса или в статических методах внешнего класса, необходимо использовать объект внешнего класса:

new Town().new Street();
// or
town.new Street();

Если необходимо получить ссылку на внутренний класс во внутреннем классе, используем слово this:

System.out.println("Street is " + this);

Если необходимо получить ссылку на объект внешнего класса, запишите имя внешнего класса, за которым следует точка, а затем ключевое слово this:

System.out.println("Town is " + Town.this);

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

  • final

  • abstract

  • public

  • private

  • protected

  • static – но static преобразует его во вложенный класс

  • strictfp

Если метод описан как strictfp (явно либо неявно), то JVM гарантирует, что результаты вычисления выражений с double и float в пределах метода будут одинаковыми на всех платформах. Модификатор strictfp для класса и интерфейса указывает на то, что все методы класса/интерфейса будут strictfp.

14.2. Локальный класс Java

Локальный класс (local class) определяется в блоке Java кода. На практике чаще всего объявление происходит в методе некоторого другого класса. Как и inner classes, локальные классы ассоциируются с экземпляром внешнего класса и имеют доступ к его полям и методам.

Локальный класс может обращаться к локальным переменным и параметрам метода, если они объявлены с модификатором final или являются effective final (начиная с Java 8).

Effective final переменная это переменная, которая не объявлена явно как final, но ее значение не меняется.

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

Локальные классы не могут быть объявлены как:

  • private

  • public

  • protected

  • static

Они не могут иметь внутри себя статических объявлений (полей, методов, классов). Исключением являются константы (static final).

Локальные классы могут быть объявлены как abstract или final.

Рассмотрим пример объявления локального класса:

public class Town2 {
    private String postCode = "33333";

    public void createAddress() {
        final int houseNumber = 34;
        class Street {
            public void printAddress() {
                System.out.println("PostCode is " + postCode);
                System.out.println("House Number is " + houseNumber);
            }
        }
        Street street = new Street();
        street.printAddress();
    }

    public static void main(String[] args) {
        Town2 town = new Town2();
        town.createAddress();
    }
}

Если локальный класс объявлен внутри статического метода, он имеет доступ только к статическим переменным класса:

public class Town3 {
    private static String postCode = "33333";

    public static void createAddress() {
        final int houseNumber = 34;
        class Street {
            public void printAddress() {
                System.out.println("PostCode is " + postCode);
                System.out.println("House Number is " + houseNumber);
            }
        }
        Street street = new Street();
        street.printAddress();
    }

    public static void main(String[] args) {
        Town3.createAddress();
    }
}

14.3. Анонимный класс

Анонимный класс (anonymous class) - это локальный класс без имени. Используется тогда, когда нужно переопределить метод класса или интерфейса. Класс одновременно объявляется и инициализируется.

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

Рассмотрим пример анонимного класса:

public class Potato {
    public void peel() {
        System.out.println("Чистим картошку.");
    }
}
public class Food {
    public static void main(String[] args) {
        Potato potato = new Potato() {
            @Override
            public void peel() {
                System.out.println("Чистим картошку в анонимном классе.");
            }
        };
        potato.peel();
    }
}

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

public class AnotherFood {
    public static void main(String[] args) {
        Potato potato = new Potato() {
            public void fry() {
                System.out.println("Жарим картошку в анонимном классе.");
            }

            @Override
            public void peel() {
                System.out.println("Чистим картошку в анонимном классе.");
                fry();
            }
        };
        potato.peel();
        //Ошибка компиляции
        //potato.fry();
    }
}

Случаи использования анонимного класса:

  • Тело класса является очень коротким.

  • Нужен только один экземпляр класса.

  • Класс используется в месте его создания или сразу после него.

  • Имя класса не важно и не облегчает понимание кода.

Анонимный класс могут также расширять интерфейс:

public interface Moveable {
    void moveRight();
    void moveLeft();
}
public class MoveableDemo {
    public static void main(String[] args) {
        Moveable moveable = new Moveable() {
            @Override
            public void moveRight() {
                System.out.println("MOVING RIGHT!!!");
            }

            @Override
            public void moveLeft() {
                System.out.println("MOVING LEFT!!!");
            }
        };
        moveable.moveRight();
        moveable.moveLeft();
    }
}

14.4. Статический вложенный класс

Статический вложенный класс (static nested class) – это внутренний класс объявленный с модификатором static.

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

Рассмотрим примеры объявления статических вложенных классов:

public class Town4 {
    public static class Street {
        public void go() {
            System.out.println("Go to the Street.");
        }
    }
}
public class City {
    public static class District {
        public void go() {
            System.out.println("Go to the District.");
        }
    }

    public static void main(String[] args) {
        Town4.Street street = new Town4.Street();
        street.go();
        District district = new District();
        district.go();
    }
}