ООП: реализация и основные идеи.
Создание объектов и их наследников. Процесс компиляции.
Рассказанное ниже в точности верно для С++ и практически совпадает тем, что происходит в 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
. Возможное решение - можно было бы поместить в объект адреса функций, но с увеличением их числа размер объекта тоже будет расти, а это плохо (см. рисунок).
myI
|
|
адрес foo() адрес bar() адрес newFunction(…) |
К тому же, если все-таки хранить эти адреса, то они дублируются во всех объектах типа Base
.
Выход – хранить в объекте всего один указатель на таблицу виртуальных функций (см.рисунок).
Посмотрим, что происходит, когда мы пишем b = new Derived();
Рассмотрим рисунок.
Когда я вызываю функцию foo()
у объекта типа Base
или Derived
,
b.foo();
это скомпилируется в байт-код, который будет делать следующее:
- Сначала нужно залезть в объект
- Взять оттуда первые 4 байта – это адрес виртуальной таблицы для объекта.
- Найти в этой виртуальной таблице адрес
foo()
, перейти по нему и вызвать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(…)
, который определен для:
- 1 для строк
- 1 для объектов
- 8 для примитивных типов
- 1 без параметров
Стандартный метод 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) { ... }
Различия между первым и вторым способом:
- Первый способ дороже – потому что для него надо вызвать метод.
- Если ссылка на
ci0 == null
, то при использовании первого способа все сломается, а второй сработает, поэтому, вообще говоря, первый способ хуже.
Но тогда не понятно, какая польза от метода 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; }
Эта реализация метода вычисления суммы элементов плоха по следующим причинам:
- функция вычисления суммы элементов списка практически совпадает с функцией вычисления длины списка, произведения элементов и т. п. – то есть придется каждый раз переписывать практически один и тот же код. Но ведь обычно его не переписывают, а копируют, и в процессе копирования можно забыть что-либо исправить. Такую ошибку найти уже очень трудно.
- эта функция противоречит идеологии ООП.
Поясним вторую причину.
Если мне вдруг потребуется еще какая-нибудь другая функция, я полезу исправлять список. Велик риск того, что где-то я забуду исправить что-либо, или подпорчу случайно какие-то уже отлаженные на предыдущем исправлении методы, и такие ошибки найти уже будет очень сложно. Идея ООП состоит в том, что
Объекты нужны для того, чтобы разделить программу на куски и каждый кусок может быть написан и отлажен по отдельности.
Теперь напишем правильный метод с точки зрения ООП.
Первый вариант.
Завожу класс (права доступа нас пока не интересуют)
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 есть только у листьев дерева. Поэтому иерархия слева – это хороший стиль, и, соответственно, иерархия справа – плохой стиль.
Итак, вторая проблема решена. Теперь хотим решить первую проблему.
Избавимся от метода 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; } }