Определение классов. Наследование и полиморфизм

1 Теоретическая часть

1.1 Определение классов

Класс – это структурированный тип данных, набор элементов данных различных типов и функций для работы с этими данными. Описание класса состоит из спецификаторов (например, public, final), имени, имени базового класса, списка интерфейсов и тела, заключенного в фигурные скобки.

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

class Rectangle {
    double width;
    double height;
    double area() {
        return width * height;
    }
}

После последней закрывающейся фигурной скобки не следует ставить точку с запятой. Методы всегда реализуются внутри определения класса.

При создании объекта класса поля инициализируются значениями по умолчанию (нулями или null для ссылок). В Java допускает инициализацию полей начальными значениями:

class Rectangle {
    double width = 10;
    double height = 20;
    double area() {
        return width * height;
    }
}

Можно создать специальный блок инициализации внутри тела класса. Такой блок будет выполняться каждый раз при создании нового объекта:

class Rectangle {
    double width;
    double height;
    {
        width = 10;
        height = 20;
    }
    double area() {
        return width * height;
    }
}

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

Rectangle rect = new Rectangle(); // rect - имя ссылки на объект
double a = rect.area();           // a = 200
rect.width = 15;                  // изменение значения поля
double b = rect.area();           // b = 300

1.2 Конструкторы. Ссылка this

Конструктор представляет собой функцию, инициализирующую объект. Имя конструктора совпадает с именем класса. Нельзя указывать тип результата конструктора. В классе может быть определено несколько конструкторов, отличающихся списками параметров. Если ни один конструктор явно не определен, автоматически создается конструктор по умолчанию (без параметров). Такой конструктор инициализирует все поля начальными значениями по умолчанию. Значения по умолчанию для целых и вещественных чисел – 0, для booleanfalse, для char – символ c кодом 0.

Вместо блока инициализации можно проинициализовать поля в конструкторе. Тогда код класса будет таким:

class Rectangle {
    double width;
    double height;

    Rectangle() { // конструктор
        width = 10;
        height = 20;
    }

    double area() {
        return width * height;
    }
}

Для удобства создания объекта можно сделать несколько конструкторов:

class Rectangle {
    double width;
    double height;

    Rectangle() { // конструктор
        width = 10;
        height = 20;
    }

    Rectangle(double width, double height) { // конструктор
        this.width = width;
        this.height = height;
    }

    double area() {
        return width * height;
    }
}

Теперь объект можно создать, указав сразу значения width и height:

Rectangle rect = new Rectangle(25, 35); // width = 25, height = 35

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

double area() {
    return this.width * this.height;
}

Однако в данном случае использование this не целесообразно.

Один конструктор можно вызвать из другого с использованием слова this, после которого следуют необходимые аргументы.

class Rectangle {
    double width;
    double height;
    Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    Rectangle() {
        this(10, 20); // вызов другого конструктора
    }
}

Примечание: в среде IntelliJ IDEA конструктор с параметрами, соответствующими полям, может быть сгенерирован через функцию главного меню Code | Generate... | Constructor, далее выбираются необходимые поля, которым будут соответствовать параметры конструктора. В частности, можно сгенерировать конструктор без параметров.

В Java нет деструкторов. Можно создать специальный метод finalize(), который вызывается сборщиком мусора перед ликвидацией объекта. В некоторых случаях объект может быть не удален сборщиком мусора никогда (памяти хватало до конца программы), следовательно, метод finalize() может быть никогда не вызван.

Методы и поля могут быть объявлены с ключевым словом static. Обращение к таким полям и методам может осуществляться без создания экземпляра класса (объекта). Статические поля являются альтернативой отсутствующим в Java глобальным переменным. Статические поля могут быть проинициализированы при описании:

class SomeClass {
    static double x = 10;
    static int    i = 20;
}

Можно создать специальный блок статической инициализации:

class SomeClass {
    static double x;
    static int i;
    static {
        x = 10;
        i = 20;
    }
}

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

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

SomeClass.x = 30;
SomeClass s = new SomeClass();
s.x = 40;

Объект, созданный с помощью new, содержит свою копию всех данных (полей), кроме статических.

1.3 Спецификаторы доступа

Спецификатор доступа (спецификатор видимости) – ключевое слово, предшествующее описанию элемента класса, и определяющее, из каких частей кода программы может быть осуществлен доступ к элементу класса. Java поддерживает закрытый (private), защищенный (protected) и открытый (public) уровни доступа. Сам класс может быть объявлен как public. Java требует отдельной спецификации доступа для каждого элемента (или группы полей с одинаковым типом, перечисленным через запятую).

Доступ к закрытым (private) элементам класса ограничен методами внутри класса.

Открытые (public) элементы открытого класса могут быть доступны из любой функции любого пакета.

Элементы класса без атрибутов доступа имеют видимость по умолчанию. Такой доступ еще называют "дружественным". Все прочие классы данного пакета имеют доступ к таким элементам как к открытым. Извне пакета такие элементы вообще недоступны.

Доступ к защищенным (protected) элементам класса, определенном в некотором пакете, ограничен методами данного класса и производных классов любых пакетов, а также методами классов данного пакета.

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

public class SomeClass {
    private int i;
    private double x;

    public int getI() {
        return i;
    }

    public void setI(int i) {
        this.i = i;
    }

    public double getX() {
        return x;
    }

    public void setX(double x) {
        this.x = x;
    }
}

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

Примечание: в среде IntelliJ IDEA геттеры и сеттеры можно автоматически сгенерировать с помощью функции главного меню Code | Generate... | Getter and Setter, далее выбираются необходимые поля, которым будут методы доступа. Можно также сгенерировать только геттеры или только сеттеры.

Внутри классов можно определять константы. Константы могут быть двух видов - статические и нестатические. Статическую константу создает компилятор. По соглашению ее имя должно содержать только заглавные буквы:

public static final double PI = 3.14159265;

Значение нестатической константы задается один раз - либо на месте определения или в блоке инициализации (тогда это значение будет одинаковым для всех экземпляров), либо в конструкторе:

class ConstDemo {
    public final int one = 1;
    public final int two; 

    {
        two = 2;
    }

    public final int other;

    public ConstDemo(int other) {
        this.other = other;
    }
}

Константы можно определять как public.

1.4 Композиция

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

class X {
}

class Y {
}

class Z {
    X x = new X();
    Y y;
    Z() {
        y = new Y();
    }
}

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

Отношение, моделируемое композицией, часто называют отношением "has-a".

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

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

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

В Java разрешается только единичное наследование классов - у класса может быть только один базовый класс. Наследование всегда открытое. В Java нет защищенного и закрытого наследования. Наследование имеет следующий синтаксис:

class DerivedClass extends BaseClass {
    // тело класса
}

Функции производного класса имеют доступ только к элементам, описанным в разделах public и protected (защищенные). Члены класса, объявленные как защищенные, могут использоваться классами-потомками, а также в пределах пакета. Закрытые члены класса недоступны даже для его потомков.

Все классы Java непосредственно или опосредованно происходят от класса java.lang.Object. Этот класс предоставляет набор полезных методов, таких как toString() для приведения любого объекта к строковому представлению и т.д. Базовый класс Object никогда явно не указывается.

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

Ключевое слово super используется для доступа к элементам базового класса из производного класса, в частности:

  • для вызова перекрытого метода базового класса;
  • для передачи параметров конструктору базового класса.

Например:

class BaseClass {
    int i, j;
    BaseClass(int i, int j) {
        this.i = i;
        this.j = j;
    }
}

class DerivedClass extends BaseClass {
    int k;
    DerivedClass(int i, int j, int k) {
        super(i, j);
        this.k = k;
    }
}

Доступ к базовому классу с использованием super разрешен только в конструкторах и нестатических методах.

Классы могут быть определены с модификатором final. Финальные классы не могут использоваться в качестве базовых. Методы с модификатором final не могут быть перекрыты. Например:

final class A {
    void f() { }
}

class B {
    final void g() { } 
}

class C extends A { // Ошибка! Нельзя наследовать от A
}

class D extends B {
    void g() { }    // Ошибка! g() нельзя перекрыть
}

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

class Base {
    static void f(Base b) { }
}

class Derived extends Base {
    public static void main(String[] args) {
        Base b;
        b = new Derived(); // Неявное приведение
        Derived d = new Derived();
        f(d);              // Неявное приведение
    }
}

Обратное приведение необходимо производить явно:

Base b = new Base();
Derived d = (Derived) b;    

1.6 Полиморфизм

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

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

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

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

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

Все классы Java является полиморфными, поскольку таковым является класс java.lang.Object. В частности, благодаря полиморфизму каждый класс может определить свою виртуальную функцию toString(), которая будет вызвана для автоматического получения данных об объекте в виде строки.

При перекрытии защищенных (protected) методов можно повышать их уровень видимости до public.

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

объект instanceof класс

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

if(x instanceof SomeClass)
    ((SomeClass)x).someMethod();

1.7 Абстрактные классы и методы

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

abstract class SomeConcept {
  . . .
}

Абстрактный класс может содержать абстрактные методы, такие, для которых не приводится реализация. Такие методы не имеют тела функции. Их объявление аналогично объявлению функций-элементов в С++, но объявлению должно предшествовать ключевое слово abstract.

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

abstract class Shape {
    int x, y;
    . . .
    void moveTo(int newX, int newY) {
        . . .
    }
    abstract void draw();
}

Конкретные классы, произведенные от Shape, такие как Circle или Rectangle, определяют реализацию метода draw().

class Circle extends Shape 
{
    void draw() {
        . . .
    }
}

class Rectangle extends Shape {
    void draw() {
        . . .
    }
}

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

В UML имена абстрактных классов и функций пишутся курсивом.

Примечание. Для переопределения методов в среде IntelliJ IDEA можно автоматически сгенерировать каркас кода с помощью функции главного меню Code | Generate | Override Methods....

1.8 Аннотации (метаданные)

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

public class MyClass extends Object {
  
    @Override
    public String toString() {
        return "My overridden method!";
    }
}

Можно привести другие примеры аннотаций:

  • @SuppressWarnings("идентификатор_предупреждения") - предупреждения компилятора не должны выдаваться в аннотированном элементе;
  • @Deprecated - использование аннотированного элемента не является более желательным.

Java позволяет определять собственные аннотации. Соответствующий механизм будет рассмотрен позже.

1.9 Клонирование объектов и проверка эквивалентности

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

В базовом классе java.lang.Object имеется функция clone(), использование которой по умолчанию позволяет скопировать объект поэлементно. Эта функция также определена для массивов, строк и других стандартных классов. Например, так можно получить копию существующего массива и работать с этой копией:

package ua.inf.iwanoff.fifth;

import java.util.Arrays;

public class ArrayClone {
  
    public static void main(String[] args) {
        int[] a1 = { 1, 2, 3, 4 };
        int[] a2 = a1.clone(); // Копия элементов
        System.out.println(Arrays.toString(a2)); // [1, 2, 3, 4]
        a1[0] = 10; // меняем первый массив
        System.out.println(Arrays.toString(a1)); // [10, 2, 3, 4]
        System.out.println(Arrays.toString(a2)); // [1, 2, 3, 4]
    }

}

Для того, чтобы можно было клонировать объекты пользовательских классов, эти классы должны реализовывать интерфейс Cloneable. Этот интерфейс не объявляет ни одного метода. Он всего лишь указывает, что объекты данного класса можно клонировать. В противном случае вызов функции clone() приведет к генерации исключения типа CloneNotSupportedException. Механизм исключений будет рассмотрен позже. Заметим только, что функция может проигнорировать исключение, если после ее заголовка добавить throws ИмяКлассаИсключения.

Допустим, нам нужно клонировать объекты класса Human, включающего два поля типа String - name и surname. Добавляем к описанию класса реализацию интерфейса Cloneable, генерируем конструктор с двумя параметрами, для удобства вывода содержимого полей перекрываем функцию toString(). В функции main() осуществляем тестирование клонирования объекта:

package ua.inf.iwanoff.fifth;

public class Human implements Cloneable {
    private String name;
    private String surname;
  
    public Human(String name, String surname) {
        super();
        this.name = name;
        this.surname = surname;
    }

    @Override
    public String toString() {
        return name + " " + surname;
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Human human1 = new Human("John", "Smith");
        Human human2 = (Human) human1.clone();
        System.out.println(human2); // John Smith
        human1.name = "Mary";
        System.out.println(human1); // Mary Smith
        System.out.println(human2); // John Smith
    }

}

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

Для удобства использования функции clone() ее можно перекрыть, изменив ее тип результата и сделав открытой. Благодаря наличию этой функции упростится клонирование (не нужно будет каждый раз приводить тип):

  @Override
  public Human clone() throws CloneNotSupportedException {
      return (Human) super.clone();
  }
  . . .
  
  Human human2 = human1.clone();

Стандартное клонирование, реализованное в классе java.lang.Object, позволяет создавать копии объектов, поля которых - типы значения и тип String (а также классы-оболочки). Если поля объекта - ссылки на массивы или другие типы, необходимо применять так называемое "глубокое" клонирование. Допустим, некоторый класс SomeCloneableClass содержит два поля типа double и массив целых. "Глубокое" клонирование обеспечит создание отдельных массивов для различных объектов.

package ua.inf.iwanoff.fifth;

import java.util.Arrays;

public class SomeCloneableClass implements Cloneable {
    private double x, y;
    private int[] a;
  
    public SomeCloneableClass(double x, double y, int[] a) {
        super();
        this.x = x;
        this.y = y;
        this.a = a;
    }

    @Override
    protected SomeCloneableClass clone() throws CloneNotSupportedException {
        SomeCloneableClass scc = (SomeCloneableClass) super.clone(); // копируем x и y
        scc.a = a.clone(); // теперь два объекта работают с различными массивами
        return scc;
    }

    @Override
    public String toString() {
        return " x=" + x + " y=" + y + " a=" + Arrays.toString(a);
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        SomeCloneableClass scc1 = new SomeCloneableClass(0.1, 0.2, new int[] { 1, 2, 3 });
        SomeCloneableClass scc2 = scc1.clone();
        scc2.a[2] = 4;
        System.out.println("scc1:" + scc1);
        System.out.println("scc2:" + scc2);
    }

}

Для того, чтобы убедиться, что клонированные объекты одинаковые, хорошо было бы иметь возможность автоматического сравнения всех полей. Ссылочная модель объектов Java не позволяет сравнивать содержимое объектов с помощью операции сравнения (==), так как при этом сравниваются ссылки. Для сравнения значений по полям целесообразно использовать функцию equals(), определенную в классе java.lang.Object. Для классов, поля которых представляют собой типы-значения, метод класса обеспечивает поэлементное сравнение. Если же поля представляют собой ссылки на объекты, необходимо явно перекрывать функцию equals(). Приведем полный пример с классом Human.

package ua.inf.iwanoff.fifth;

public class Human implements Cloneable {
    private String name;
    private String surname;
   
    public Human(String name, String surname) {
        super();
        this.name = name;
        this.surname = surname;
    }

    @Override
    public boolean equals(Object obj) {
        Human h = (Human) obj;
        return name.equals(h.name) && surname.equals(h.surname);
    }

    @Override
    public Human clone() throws CloneNotSupportedException {
        return (Human) super.clone();
    }

    @Override
    public String toString() {
        return name + " " + surname;
    }

    public static void main(String[] args) throws CloneNotSupportedException {
        Human human1 = new Human("John", "Smith");
        Human human2 = human1.clone();
        System.out.println(human2);
        human1.name = "Mary";
        System.out.println(human1);
        System.out.println(human2);
        human2.name = new String("Mary");
        System.out.println(human2);
        System.out.println(human1.equals(human2)); // true
    }

}

Если бы метод equals() не был определен, последнее сравнение дало бы false.

Вместе с принято переопределять метод hashCode(), описанный в классе Object. и реализующий так называемое хеширование. Хеширование (hashing) – это процесс преобразования данных об объекте уникальный код с использованием некоторого формального алгоритма. В широком смысле в результате должны получаться последовательности бит фиксированной длины, в частном случае – просто целое число. Это преобразование осуществляет так называемая хеш-функция, или функция хеширования. Функция хеширования должна следовать следующему правилу: хеш-функция должна возвращать одинаковый хеш-код всякий раз, когда она применена к одинаковым или равным объектам.

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

Конкретные стандартные классы релизуют свои хэш-функции. Например, для строк значение хэш-функции вычисляется по формуле:

s[0]*31n-1 + s[1]*31n-2 + ... + s[n-1]

Здесь s[0], s[1] и т.д. - коды соответствующих символов.

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

2 Примеры программ

2.1 Точка на плоскости

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

  • вычисление расстояния от точки до начала координат;
  • вычисление расстояния между двумя точками.

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

package ua.inf.iwanoff.fifth;

public class Point {
    private double x, y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double distance() {
        return Math.sqrt(x * x + y * y);
    }

    public static double distance(Point p1, Point p2) {
        return Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + 
                         (p1.y - p2.y) * (p1.y - p2.y));
    }

    public static void main(String[] args) {
        Point p1 = new Point(3, 4);
        System.out.println(p1.distance());
        Point p2 = new Point(4, 5);
        System.out.println(distance(p1, p2));
    }
}

2.2 Линейное уравнение

Допустим, необходимо спроектировать класс, представляющий линейное уравнение. Поля этого класса – коэффициенты a и b и корень x, который необходимо найти. Создаем класс LinearEquation, содержащий функцию main(). После добавления необходимых полей генерируем геттеры и сеттеры. Геттеры необходимы для всех полей, а сеттры – для полей a и b. Далее к классу добавляем функцию solve(), возвращающую false или true в зависимости от того, можно ли решить уравнение. Тестирование осуществляется в функции main(). Получим следующую программу:

package ua.inf.iwanoff.fifth;

public class LinearEquation {
    private double a, b, x;
  
    public double getA() {
        return a;
    }
  
    public void setA(double a) {
        this.a = a;
    }
  
    public double getB() {
        return b;
    }
  
    public void setB(double b) {
        this.b = b;
    }
  
    public double getX() {
        return x;
    }
  
    public boolean solve() {
        if (a == 0) {
            return false;
        }
        x = - b / a;
        return true;
    }
  
    public static void main(String[] args) {
        LinearEquation e = new LinearEquation();
        e.setA(1);
        e.setB(-2);
        if (e.solve()) {
            System.out.println("x = " + e.getX());
        }
        else {
            System.out.println("Нет решений!");
        }
        e.setA(0);
        e.setB(4);
        if (e.solve()) {
            System.out.println("x = " + e.getX());
        }
        else {
            System.out.println("Нет решений!");
        }
    }
}

2.3 Простые дроби

Допустим, необходимо создать класс для представления простой дроби. Поля этого класса (n и d) – соответственно числитель и знаменатель. Поскольку важно, чтобы знаменатель не был бы равен 0, конструктор без параметров инициализирует знаменатель единицей. Конструктор с параметрами инициализируется данными с проверкой их допустимости. Функция System.exit() осуществляет аварийное прекращение работы программы:

public class Fraction {
    private int n, d;
    
    public Fraction() {
        n = 0;
        d = 1;
    }
  
    public Fraction(int numerator, int denominator) {
        if (denominator <= 0) {
            System.err.println("Знаменатель неположительный!");
            System.exit(1);
        }
        n = numerator;
        d = denominator;
    }

Можно автоматически сгенерировать функции доступа по чтению (getters):

    public int getDenominator() {
        return d;
    }
  
    public int getNumerator() {
        return n;
    }

Функция set() проверяет допустимость данных и генерирует исключение:

    public void set(int numerator, int denominator) {
        if (denominator <= 0){
            System.err.println("Знаменатель неположительный!");
            System.exit(1);
        }
        n = numerator;
        d = denominator;
    }

Благодаря перекрытию функции toString() можно управлять механизмом получения строкового представления данных объекта:

    public String toString() {
        return n + "/" + d;
    }

Функция sum() осуществляет сложение дробей. Функция reduce() осуществляет сокращение дроби:

    public static Fraction sum(Fraction a, Fraction b) {
        Fraction c = new Fraction();
        c.n = a.n * b.d + a.d * b.n;
        c.d = a.d * b.d;
        c.reduce();
        return c;
    }

    protected Fraction reduce() {
        int num = n, den = d;
        while (num != den) {
            if (num > den) {
                num -= den;
            }
            else {
                den -= num;
            }
        }
        n /= num;
        d /= num;
        return this;
    }

В функции main() осуществляется тестирование. Приведем весь текст программы:

package ua.inf.iwanoff.fifth;

public class Fraction {
    private int n, d;
  
    public Fraction() {
        n = 0;
        d = 1;
    }
  
    public Fraction(int numerator, int denominator) {
        if (denominator <= 0) {
            System.err.println("Знаменатель неположительный!");
            System.exit(1);
        }
        n = numerator;
        d = denominator;
    }
  
    public int getDenominator() {
        return d;
    }
  
    public int getNumerator() {
        return n;
    }
  
    public void set(int numerator, int denominator) {
        if (denominator <= 0){
            System.err.println("Знаменатель неположительный!");
            System.exit(1);
        }
        n = numerator;
        d = denominator;
    }
  
    public String toString() {
        return n + "/" + d;
    }
  
    public static Fraction sum(Fraction a, Fraction b) {
        Fraction c = new Fraction();
        c.n = a.n * b.d + a.d * b.n;
        c.d = a.d * b.d;
        c.reduce();
        return c;
    }
  
    protected Fraction reduce() {
        int num = n, den = d;
        while (num != den) {
            if (num > den) {
                num -= den;
            }
            else {
                den -= num;
            }
        }
        n /= num;
        d /= num;
        return this;
    }
  
    public static void main(String[] args) {
        Fraction a = new Fraction(10, 20);
        System.out.println("a = " + a.reduce());
        Fraction b = new Fraction(1, 3);
        System.out.println("b = " + b.reduce());
        System.out.println("a + b = " + sum(a, b));
    }
}

2.4 Иерархия объектов реального мира

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

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

Можно предложить следующую иерархию классов:.

package ua.inf.iwanoff.fifth;

//Иерархия классов
class Region {
    private String name;
    private double area;
  
    public Region(String name, double area) {
        this.name = name;
        this.area = area;
    }

    public String getName() {
        return name;
    }

    public double getArea() {
        return area;
    }

    @Override
    public String toString() {
        return getClass().getName() + ": \t" + name + ".   \tТерритория " + area + " кв.км.";
    }
}

class PopulatedRegion extends Region {
    private int population;
    public PopulatedRegion(String name, double area, int population) {
        super(name, area);
        this.population = population;
    }

    public int getPopulation() {
        return population;
    }

    public int density() {
        return (int) (population / getArea());
    }

    @Override
    public String toString() {
        return super.toString() + "    \tНаселение " + population + 
               " чел.\tПлотность населеня " + density() + " чел/кв.км.";
    }
}

class Country extends PopulatedRegion {
    private String capital;

    public Country(String name, double area, int population, String capital) {
        super(name, area, population);
        this.capital = capital;
    }

    public String getCapital() {
        return capital;
    }

    @Override
    public String toString() {
        return super.toString() + "\tСтолица " + capital;
    }
}

class City extends PopulatedRegion {
    private int boroughs; // Количество районов

    public City(String name, double area, int population, int boroughs) {
        super(name, area, population);
        this.boroughs = boroughs;
    }

    public int getBoroughs() {
        return boroughs;
    }

    @Override
    public String toString() {
        return super.toString() + "\tРайонов - " + boroughs;
    }
}

class Island extends PopulatedRegion {
    private String sea;

    public Island(String name, double area, int population, String sea) {
        super(name, area, population);
        this.sea = sea;
    }

    public String getSea() {
        return sea;
    }

    @Override
    public String toString() {
        return super.toString() + "\tМоре - " + sea;
    }  

}

public class Regions {

    public static void main(String[] args) {
        Region[] a = new Region[4];
        a[0] = new City("Киев", 839, 2679000, 10);
        a[1] = new Country("Украина", 603700, 46294000, "Киев");
        a[2] = new City("Харьков", 310, 1461000, 9);
        a[3] = new Island("Змеиный", 0.2, 30, "Черное");
        for (int i = 0; i < a.length; i++) {
            System.out.println(a[i]);
        }
    }
}

Благодаря полиморфизму, данные об объекте выводятся в зависимости от его типа. В примере используется вызов функций getClass().getName(), позволяющий получить имя класса для каждого объекта. Функция getClass() является частью механизма RTTI (Run-Time Type Information), который будет рассмотрен позже.

2.5 Класс для представления массива точек

2.5.1 Постановка задачи и создание абстрактного класса

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

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

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

package ua.inf.iwanoff.fifth;

public abstract class AbstractArrayOfPoints {

    // Запись новых координат точки:
    public abstract void setPoint(int i, double x, double y);

    // Получение X точки i:
    public abstract double getX(int i);

    // Получение Y точки i:
    public abstract double getY(int i);

    // Получение количества точек:
    public abstract int count();

    // Добавление точки в конец массива:
    public abstract void addPoint(double x, double y);

    // Удаление последней точки:
    public abstract void removeLast();

    // Сортировка по значениям X:
    public void sortByX() {
        boolean mustSort; // Повторяем до тех пор,
                          // пока mustSort равно true
        do {
            mustSort = false;
            for (int i = 0; i < count() - 1; i++) {
                if (getX(i) > getX(i + 1)) {
                    // меняем элементы местами
                    double x = getX(i);
                    double y = getY(i);
                    setPoint(i, getX(i + 1), getY(i + 1));
                    setPoint(i + 1, x, y);
                    mustSort = true;
                }
            }
        }
        while (mustSort);
    }

    // Аналогично можно реализовать функцию sortByY()
    // Вывод точек в сторку:
    @Override
    public String toString() {
        String s = "";
        for (int i = 0; i < count(); i++) {
            s += "x = " + getX(i) + " ty = " + getY(i) + "\n";
        }
        return s + "\n";
    }

    // Тестируем сортировку на четырех точках:
    public void test() {
        addPoint(22, 45);
        addPoint(4, 11);
        addPoint(30, 5.5);
        addPoint(-2, 48);
        sortByX();
        System.out.println(this);
    }
}

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

2.5.2 Реализация через массив объектов типа Point

Первой из возможных реализаций будет создание класса Point и использование массива ссылок на Point. В том же проекте создаем класс ArrayOfPointObjects. Если классу явно указать базовый (AbstractArrayOfPoints), в окне редактора будут индицированы ошибки, связанные с наличием неперекрытых абстрактных методов. С помощью функции меню Code | Generate | Override Methods... можно автоматически сгенерировать каркас методов, которые нужно перекрыть. В результате получим следующий код:

package ua.inf.iwanoff.fifth;

public class ArrayOfPointObjects extends AbstractArrayOfPoints {

    @Override
    public void setPoint(int i, double x, double y) {

    }

    @Override
    public double getX(int i) {
        return 0;
    }

    @Override
    public double getY(int i) {
        return 0;
    }

    @Override
    public int count() {
        return 0;
    }

    @Override
    public void addPoint(double x, double y) {

    }
  
    @Override
    public void removeLast() {

    }

}

Класс для представления точки можно добавить в том же пакете. Класс Point будет содержать два поля и конструктор:

package ua.inf.iwanoff.fifth;

public class Point {
    private double x, y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }

    public double getX() {
        return x;
    }

    public double getY() {
        return y;
    }

    public void setPoint(double x, double y) {
        this.x = x;
        this.y = y;
    }
}

В классе ArrayOfPointObjects создаем поле - ссылку на массив Point и инициализируем ее пустым массивом. Реализация большинства функций представляется очевидной. Наибольшую трудность представляют функции добавления и удаления точек. В обоих случаях необходимо создать новый массив нужной длины и переписать в него содержимое из старого. В функции main() осуществляем тестирование. Весь код файла AbstractArrayOfPoints.java будет иметь вид:

package ua.inf.iwanoff.fifth;

public class ArrayOfPointObjects extends AbstractArrayOfPoints {

    private Point[] p = { };

    @Override
    public void setPoint(int i, double x, double y) {
        if (i < count()) {
            p[i].setPoint(x, y);
        }
    }

    @Override
    public double getX(int i) {
        return p[i].getX();
    }

    @Override
    public double getY(int i) {
        return p[i].getY();
    }

    @Override
    public int count() {
        return p.length;
    }

    @Override
    public void addPoint(double x, double y) {
        // Создаем массив, больший на один элемент:
        Point[] p1 = new Point[p.length + 1];
        // Копируем все элементы:
        System.arraycopy(p, 0, p1, 0, p.length);
        // Записываем новую точку в последний элемент:
        p1[p.length] = new Point(x, y);
        p = p1; // Теперь p указывает на новый массив
    }

    @Override
    public void removeLast() {
        if (p.length == 0)
            return; // Массив уже пустой
        // Создаем массив, меньший на один элемент:
        Point[] p1 = new Point[p.length - 1];
        // Копируем все элементы, кроме последнего:
        System.arraycopy(p, 0, p1, 0, p1.length);
        p = p1; // Теперь p указывает на новый массив
    }

    public static void main(String[] args) {
        // Можно создать безымянный объект:
        new ArrayOfPointObjects().test();
    }
}

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

2.5.3 Реализация через два массива

Альтернативная реализация предполагает создание двух массивов для отдельного хранения значений X и Y. Создаем класс ArrayWithTwoArrays, используя аналогичные опции. В классе ArrayWithTwoArrays создаем два поля - ссылки на массивы вещественных чисел и инициализируем их пустыми массивами. Реализация функций аналогична предыдущему варианту. В функции main() осуществляем тестирование:

package ua.inf.iwanoff.fifth;

public class ArrayWithTwoArrays extends AbstractArrayOfPoints {
    private double[] ax = { };
    private double[] ay = { };

    @Override
    public void setPoint(int i, double x, double y) {
        if (i < count()) {
            ax[i] = x;
            ay[i] = y;
        }
    }

    @Override
    public double getX(int i) {
        return ax[i];
    }

    @Override
    public double getY(int i) {
        return ay[i];
    }

    @Override
    public int count() {
        return ax.length; // Можно y.length, они одинаковые
    }

    @Override
    public void addPoint(double x, double y) {
        double[] ax1 = new double[ax.length + 1];
        System.arraycopy(ax, 0, ax1, 0, ax.length);
        ax1[ax.length] = x;
        ax = ax1;
        double[] ay1 = new double[ay.length + 1];
        System.arraycopy(ay, 0, ay1, 0, ay.length);
        ay1[ay.length] = y;
        ay = ay1;
    }

    @Override
    public void removeLast() {
        if (count() == 0) {
            return;
        }
        double[] ax1 = new double[ax.length - 1];
        System.arraycopy(ax, 0, ax1, 0, ax1.length);
        ax = ax1;
        double[] ay1 = new double[ay.length - 1];
        System.arraycopy(ay, 0, ay1, 0, ay1.length);
        ay = ay1;
    }

    public static void main(String[] args) {
        new ArrayWithTwoArrays().test();
    }
}

Результаты должны быть идентичными.

2.6 Решение уравнения методом дихотомии

Допустим, необходимо решить методом дихотомии (деления отрезка пополам) произвольное уравнение.

f(x) = 0

Численные методы решения уравнений предполагают многократное вычисление в различных точках значений функциональной зависимости f(x), определяющей левую часть уравнения. В отличие от алгоритма решения, f(x) может меняться в различных задачах. Необходимо реализовать механизм передачи информации о данной зависимости классу, который отвечает за решение уравнения. Для этого можно предложить использование абстрактного класса. Создаем новый класс - AbstractEquation, содержащий абстрактную функцию f() и функцию решения уравнения - solve():

package ua.inf.iwanoff.fifth;

public abstract class AbstractEquation {

    abstract public double f(double x);

    public double solve(double a, double b, double eps) {
        double x = (a + b) / 2;
        while (Math.abs(b - a) > eps) {
            if (f(a) * f(x) > 0) {
                a = x;
            }
            else {
                b = x;
            }
            x = (a + b) / 2;
        }
        return x;
    }
}

Теперь можно создать конкретный класс с конкретной функцией f():

package ua.inf.iwanoff.fifth;

public class SpecificEquation extends AbstractEquation {

    public double f(double x) {
        return x * x - 2;
    }

    public static void main(String[] args) {
        SpecificEquation se = new SpecificEquation();
        System.out.println(se.solve(0, 2, 0.000001));
    }
}

3 Задания на самостоятельную работу

3.1 3D-точка

Модифицировать пример программы для представления точки в трехмерном пространстве.

3.2 Простой класс

Создать класс с конструктором для описания товара (сохраняются название и цена).

3.3 Квадратное уравнение

Спроектировать класс для решения квадратного уравнения.

3.4 Простые дроби

Дополнить класс для представления простой дроби функциями перемножения дробей (статический метод) и возведения дроби в целую степень (нестатический метод). Добавить в функции main() ввод данных с клавиатуры.

3.5 Комплексные числа

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

3.6 Класс для представления массива

Добавить к классу, представляющему массив, функцию удаления последнего элемента. Добавить в функции main() ввод данных с клавиатуры.

3.7 Иерархия классов

Реализовать классы "Человек", "Гражданин", "Сотрудник". "Сотрудник на ставке", "Сотрудник с почасовой оплатой". Создать массив ссылок на различные объекты иерархии. Для каждого объекта вывести на экран строку данных о нем. В классе сотрудник определить абстрактную функцию "Начисление зарплаты за месяц". В классе "Сотрудник на ставке" описать поля "Ставка" и "Процент премии", а в классе "Сотрудник с почасовой оплатой" - поля "Часовая оплата" "Количество проработанных часов". Значения полей задавать в конструкторах. Создать массив сотрудников различных типов. Для всех сотрудников начислить и вывести заработную плату.

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

3.8 Реализация массива точек через двумерный массив*

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

3.9 Реализация массива точек через одномерный массив вещественных чисел*

Реализовать функциональность абстрактного класса AbstractArrayOfPoints, приведенного в примере, через использование одномерного массива вещественных чисел. Каждая пара чисел в массиве должна соответствовать точке. Дополнительно необходимо перекрыть функцию sortByX(), реализовав сортировку выбором.

3.10 Минимум функции*

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

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

3.11 Матрица*

Создать класс для представления именованной матрицы со строковым полем - наименованием матрицы и полем, представляющим двумерный массив. Реализовать методы клонирования, проверки эквивалентности и получения строкового представления. Осуществить тестирование.

Примечание. Клонировать следует не только массив, но и отдельно его строки.

4 Контрольные вопросы

  1. Можно ли в Java вне класса реализовывать методы, объявленные внутри класса?
  2. Чем отличаются статические и нестатические элементы класса?
  3. Как осуществляется инициализация статических данных?
  4. Где может быть расположена конструкция инициализации?
  5. Как в Java определяется дружественный доступ к элементам класса?
  6. Как можно использовать ссылки this?
  7. Как вызвать конструкторы из других конструкторов?
  8. Сколько конструкторов без параметров может быть создано в одном классе?
  9. Как создать класс, в котором нет ни одного конструктора?
  10. Почему в Java нет деструкторов?
  11. Когда вызывается метод finalize()?
  12. Какие существуют модификаторы доступа?
  13. В чем заключается смысл инкапсуляции и как она реализована в Java?
  14. Для чего используются функции доступа?
  15. Чем отличается использование функции Math.random() от средств класса java.util.Random?
  16. Какими способами можно создать новую строку?
  17. Как по умолчанию осуществляется сортировка массива строк?
  18. Можно ли изменить содержимое ранее созданной строки?
  19. Для чего используются классы StringBuffer и StringBuilder?
  20. Как осуществляется разделение строки на лексемы?
  21. Как перевести число в его строковое представление и наоборот?
  22. В чем преимущества и недостатки классов-оболочек по сравнению с соответствующими примитивными типами?
  23. Как создать объект типа Integer?
  24. В каких случаях целесообразно использовать композицию классов?
  25. Можно ли в Java полностью разместить один объект внутри другого объекта?
  26. В чем заключается содержание наследования?
  27. В чем смысл существования общего базового класса?
  28. Какие элементы базового класса не наследуются?
  29. Где и для чего можно применять ключевое слово super?
  30. Как перекрыть метод с модификатором final?
  31. Допускается ли множественное наследование классов?
  32. Можно ли неявно приводить ссылку на базовый класс к ссылке на производный класс?
  33. Какие возможности предоставляет использование полиморфизма?
  34. Чем виртуальная функция отличается от невиртуальной?
  35. Как в Java указать, что функция виртуальная?
  36. Почему функции с модификатором private не являются виртуальными?
  37. В чем смысл применения аннотаций?
  38. Для чего используется клонирование объектов и как оно реализовано?
  39. Когда нужно реализовывать "глубокое" клонирование?
  40. Когда возникает необходимость в перекрытии метода equals()?

 

up