ООП: реализация и основные идеи.

Создание объектов и их наследников. Процесс компиляции.

Рассказанное ниже в точности верно для С++ и практически совпадает тем, что происходит в Java. Рассмотрим пример.

public class Base {
    int myI;
    foo() {
        ...
    }
    bar() {
        ...
    }
    final zzz() {
        ...  
    }
}

public class Derived extends Base {
    int myJ;
    foo() {
        ...  
    }
    xxx() {
        ...
    }
}

Посмотрим, что происходит, когда мы пишем b = new Base(); При вызове функции у объекта, например, b.foo(), нужна информация, что этот объект именно типа Base, а не типа Derived. Возможное решение - можно было бы поместить в объект адреса функций, но с увеличением их числа размер объекта тоже будет расти, а это плохо (см. рисунок).


Объект типа Base
myI
адрес foo()
адрес bar()
адрес newFunction(…)

К тому же, если все-таки хранить эти адреса, то они дублируются во всех объектах типа Base.

Выход – хранить в объекте всего один указатель на таблицу виртуальных функций (см.рисунок).

Два объекта - одна таблица

Посмотрим, что происходит, когда мы пишем b = new Derived();

Рассмотрим рисунок.

ТВФ при наследовании

Когда я вызываю функцию foo() у объекта типа Base или Derived,

b.foo();

это скомпилируется в байт-код, который будет делать следующее:

Такой способ вызова функций называется динамическим связыванием.

Вызов final метода у объекта, т.е. метода, который никогда не будет перекрыт, нужно рассмотреть отдельно. У объекта типа Base есть такой метод zzz(). В таблице виртуальных функций нет ссылки на zzz(). Когда этот метод вызывается, то он сразу скомпилируется в байт-код с адресом zzz, который и вызовет ее.

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

Если теперь у наших классов будут определены какие-то конструкторы:

public class Base {
    Base() {
        foo();
    }
    foo() {
        System.out.println(1);
    }
}

public class Derived extends Base {
    int myValue = 2;    
    Derived() {
        foo();
    }
    foo() {
        System.out.println(myValue);
    }
}

Когда мы напишем new Derived(), что выведется на экран?

Сначала выполнится конструктор Base, потом конструктор Derived – и строк будет две. Во второй строке при выполнении конструктора Derived будет напечатана 2, а в первой? Ответ на этот вопрос - для каждого языка по-разному. В Java указатель на таблицу виртуальных функций в объекте – это указатель на окончательную таблицу виртуальных функций, т.е. на таблицу для Derived. И в момент вызова функции foo() у Base, которая уже перекрыта в Derived, значение myValue еще не инициализировано 2, но по умолчанию оно 0, поэтому выведется 0. Возникает опасность – в конструкторе вызывается виртуальная функция, а объект не достроен!

Вывод – использовать в конструкторе виртуальные функции – это плохая идея!

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

В Java у всех классов, кроме одного, есть предок. Если при объявлении класса extends не написано, то неявно этот класс наследуется от класса Object. У самого Object предка нет.

public class ComplexInteger extends Object

эквивалентно

public class ComplexInteger

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

public String toString() //возвращает строку из названия класса и адреса объекта.
public boolean equals(Object o) //метод проверки на равенство объектов.

Эти методы можно переопределять.

Не нужно путать два понятия – overriding и overloading.

Overriding – это перекрытие метода, т.е. в наследуемом классе мы перекроем метод с таким же названием и таким же набором типов параметров.

Overloading – это перегрузка метода, т.е. напишем метод с таким же названием, но с другим набором параметров (они могут различаться либо типом, либо количеством).

Пример overloading - метод println(…), который определен для:

Стандартный метод equals выглядит так:

public boolean equals(Object o){
    return this == o;
}

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

ComplexInteger ci0 = new ComplexInteger();
ComplexInteger ci1 = new ComplexInteger();
//первый вариант – используем метод equals
if (ci0.equals(ci1)) {
    ...
}
//второй вариант
if (ci0 == ci1) {
    ...
}

Различия между первым и вторым способом:

Но тогда не понятно, какая польза от метода equals.

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

public class ComplexInteger{	
    public boolean equals(Object o){
//тип возвращаемого значения и модификатор доступа обязательно такие, параметром должен быть Object
//первая попытка
        return ((myIm == o.myIm) && (myRe == o.myRe));

//Но! У объекта o нет полей myRe и myIm. Перепишем по-другому.
//вторая попытка
        ComplexInteger ci = (ComplexInteger) o;
        return ((myIm == o.myIm) && (myRe == o.myRe));
    }
}

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

Но! Есть опасность, что в качестве параметра я передам не ComplexInteger. Это надо проверять – и без этого не обойтись. Для этого есть специальный оператор instanceof. Заметим, что делать так - очень плохо с точки зрения ООП.

Напишем почти правильно:

public boolean equals(Object o) {
    if(!(o instanceof ComplexInteger)) {
        return false;
    }
    ComplexInteger ci = (ComplexInteger) o;
    return ((myIm == o.myIm) && (myRe == o.myRe));
}

Но если ссылки все-таки равны, то нет смысла выполнять дорогостоящую операцию приведения типа – проще сразу проверить на равенство ссылок. Это дешево на фоне остального кода метода, но если повезет, то мы много выиграем! Теперь итоговый вариант:

public boolean equals(Object o) {
    if (this == o){
        return true;
    }
    if(!(o instanceof ComplexInteger)) {
        return false;
    }
    ComplexInteger ci = (ComplexInteger) o;
    return ((myIm == o.myIm) && (myRe == o.myRe));
}

Разделение логики структур данных и логики вычислений. Идеология ООП.

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

public class List {
    private ListElement myHead;

    public int sum (){
        int sum = 0;
        for (ListElement i = myHead; i != null; i = i.myNext) {
            sum += i.getValue();
        }
        return sum;
    }
}

public class ListElement {
    private int myValue;
    private ListElement myNext;
}

Эта реализация метода вычисления суммы элементов плоха по следующим причинам:

  1. функция вычисления суммы элементов списка практически совпадает с функцией вычисления длины списка, произведения элементов и т. п. – то есть придется каждый раз переписывать практически один и тот же код. Но ведь обычно его не переписывают, а копируют, и в процессе копирования можно забыть что-либо исправить. Такую ошибку найти уже очень трудно.
  2. эта функция противоречит идеологии ООП.

Поясним вторую причину.

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

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

Теперь напишем правильный метод с точки зрения ООП.

Первый вариант.

Завожу класс (права доступа нас пока не интересуют)

public class Summator {
    int calculate(List list) {
        int sum = 0;
        for (ListElement i = list.myHead; i != null; i = i.myNext) {
            sum += i.myValue;
        }
        return sum;
    }
}

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

Раньше для вызова метода приходилось писать

list.sum();

А теперь –

new Summator().calculate(list);

Только пользы пока от этого исправления нет. Будем исправлять дальше.

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

Второй вариант.

public class Summator {
    int mySum;
    int calculate(List list) {
        mySum = 0;
        for (ListElement i = list.myHead; i != null; i = i.myNext) {
            processElement(i);
        }
    }	
	int result() {
		return mySum;
	}
	void processElement(ListElement e) {
		mySum += e.myValue;
	}
}

Мы убрали недостаток, что функция вычисления суммы элементов списка - внутренняя функция для List. Теперь нам нужно завести отдельный класс для вычисления суммы элементов, который будет переопределять метод processElement(ListElement e).

Третий вариант.

public class ListProcessor {
    int myResult;
    int calculate(List list) {
        myResult = 0;
        for (ListElement i = list.myHead; i != null; i = i.myNext) {
            processElement(i);
        }
    }
    int result() {
        return myResult;
    }
    void processElement(ListElement e) {
    }
}

class Summator extends ListProcessor {
    void processElement(ListElement e) {
        myResult += e.myValue;
    }
}

Теперь надо писать

ListProcessor s = new Summator();
s.calculate(list);
return s.result();

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

Теперь я могу написать еще один класс для вычисления длины списка. Он будет практически такой же, за исключением метода processElement(ListElement e) – переменную myResult будем увеличивать на 1. Но вот с произведением все не так здорово – из-за того, что у нас начальное значение myResult = 0, произведение посчитать не получится.

Выход – можно написать метод initialValue(), который нужно перекрывать каждый раз и устанавливать в нем соответствующее задаче значение myResult.

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

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

Метод, который мы только что написали, меня опять не устраивает, потому что есть проблема - пустое тело метода processElement(ListElement e) – можно случайно забыть его перекрыть. А без него Summator не имеет никакого смысла!

Можно сделать так, чтобы этот метод не просто можно было перекрывать, а его нужно было перекрывать. Для этого сделаем метод processElement(...) абстрактным:

abstract void processElement(ListElement e);

Теперь мы обязаны его перекрыть, иначе это ошибка времени компиляции.

Только класс ListProcessor тоже нужно сделать абстрактным

abstract public class ListProcessor {
    ...
}

Метод initialValue() теперь тоже лучше сделать абстрактным:

abstract initialValue();

Тогда в наследуемом классе мы его перекрываем:

int initialValue() {
    return 0;
}

У абстрактного класса могут быть не абстрактные методы, но абстрактные методы могут быть только у абстрактного класса!

Рассмотрим 2 рисунка – 2 иерархии объектов. Пустые квадратики означают абстрактные классы, закрашенные квадратики означают, что у них есть instance. Разберемся, какая из иерархий хорошая, а какая плохая. На практике instance есть только у листьев дерева. Поэтому иерархия слева – это хороший стиль, и, соответственно, иерархия справа – плохой стиль.

2 иерархии

Итак, вторая проблема решена. Теперь хотим решить первую проблему.

Избавимся от метода calculate(...). Теперь сумма будет считаться так:

List list = ...
list.calculate(new Summator());

Метод calculate(...) перетаскиваем в List. А в классе ListProcessor вместо abstract initialValue() напишем более общий метод abstract int initialize(), который мы также будем перекрывать.

public class List {
    ...
    int calculate(ListProcessor lp) {
        initialize();
        for (ListElement i = list.myHead; i != null; i = i.myNext) {
            lp.processElement(i);
        }
        return lp.result();
    }
}

abstract public class ListProcessor0 extends ListProcessor {
    public int initialize() {
        return myResult = 0;
    }
}

public class Summator extends ListProcessor0 {
    public void processElement(int value) {
        myResult += value;
    }
}