Последнее изменение: 30 сентября 2007г.
Overloading, overriding и другие звери
Рассказ у нас пойдет о... нет, не о хоббитах. О более прозаических вещах, а именно – о таких явлениях как overriding и overloading. И обо всем, что с ними связано.
Вот о чем мы поговорим:
Итак, первое – это...
Определения
О чем вообще идет речь.
Overloading – перегрузка. Честно сказать, я затрудняюсь дать краткое и точное определение того, что такое пререгрузка. Я бы сказал так: перегрузка – это возможность делать разные действия единообразным способом. Сам я прекрасно понимаю, что определение неочевидное, потому приведу несколько примеров.
Пример первый, хорошо известный тем, кто знаком с С++. Перегрузка операторов. Т.е. берется, например, оператор сложения – '+' – и переопределяется для класса комплексных чисел. После чего мы можем два экземпляра комплексных чисел складывать точно так же, как обычные числа.
В Java перегрузка операторов формально отсутствует, хотя на уровне виртуальной машины для нескольких случаев она реализована. Прежде всего – это операция сложения строк, которая, фактически, является конкатенацией. Начиная с Java 5 операция сложения перегружена еще и для объектных оболочек примитивов. А вместе с ней – и все арифметические операции, включая присваивание.
Пример второй – перегрузка методов. Определяется метод с тем же именем, но с другим набором параметров.
Классический случай – java.io.PrintStream.print
. Этот метод существует в 9 (девяти!) вариантах.
Разница только в типе аргумента. Собственно, именно о такой перегрузке мы и будем говорить.
Overriding – переопределение. Это определение в дочернем классе метода с тем же набором параметров, т.е. – переопределение родительского метода. Там есть свои тонкости и подводные камни, о которых мы поговорим в соответствующем разделе.
Вообще переопределение в Java отличается от того, что я помню из C++ и, если не ошибаюсь, Object Pascal. В
обоих этих языках для указания, что метод может быть переопределен в дочернем классе, используется специальное
ключевое слово virtual
. Без него переопределение невозможно. В Java ровно наоборот: переопределение
по умолчанию разрешено и запрещается ключевым словом final
.
С определениями, пожалуй, всё. Перейдем к самим явлениям. И сначала рассмотрим...
Overloading
Как я уже говорил, в Java перегрузка ограничивается методами. И конструкторами, естественно, они тоже имеют одинаковые имена. Отличительная черта перегрузки – это одинаковое имя, на возвращаемые значения и выбрасываемые исключения не накладывается никаких ограничений.
Как выбирается нужный метод – в общем-то очевидно. Ищется тот, который совпадает по типам параметров. Интереснее другое – как поведет себя компилятор, если точного совпадения нет.
Напишем простой тест:
package test; /** * Overloading test * * @author Eugene Matyushkin */ public class OverloadingTest{ public void test(float param){ System.out.println("float: "+param); } public void test(double param){ System.out.println("double: "+param); } public void test(long param){ System.out.println("long: "+param); } public void test(byte param){ System.out.println("byte: "+param); } public void test(short param){ System.out.println("short: "+param); } public static void main(String[] args){ OverloadingTest ot = new OverloadingTest(); ot.test(999999999); } }
Напоминаю, что целое число по умолчанию – int
. Т.к. метода с параметром типа int
у
нас нет, возникает вопрос: какой метод будет вызван? Результат:
long: 999999999
Т.е. вызван будет метод с параметром типа long
. Уберем этот метод! Что получим?
float: 1.0E9
Уберем метод с параметром типа float
. Вызовется ...
double: 9.99999999E8
Уберем и его. И тогда... код не скомпилируется.
То, что происходит, называется расширением (widening) типа. Разумеется, все это можно не выяснять опытным путем, а прочитать в спецификации языка: http://java.sun.com/docs/books/jls/third_edition/html/conversions.html#5.1.2. Там описаны все расширения. Опыт – он всего лишь нагляднее.
Хочу обратить внимание и на тот факт, что в случае, когда вызывается метод с типом float
,
происходит потеря точности. Этот момент упомянут в спецификации.
Перейдем теперь к следующей теме –
Overriding
Как я уже упоминал выше, переопределение – это определение в дочернем классе метода с тем же именем и набором параметров, что и в родительском. Ключевое отличие от перегрузки – к имени добавляются еще и параметры. И сразу встает вопрос – а что насчет вовращаемого значения? А исключения?
Возвращаемые значения
В Java версии младше 5 типы возвращаемого значения были обязаны совпадать. В версии 5 появилась возможность
сужения типа – использования дочернего типа. Т.е. если, например, у родительского класса был метод public
Object test()
, то в дочернем его можно переопределить методом public String test()
. Логика
тут понятна – любой код, "знающий" о типе возвращаемого значения в методе родительского класса, не пострадает,
получив вместо этого типа дочерний. Исключительно в силу определения наследования.
В плане примитивов ситуация для меня несколько непонятна, прямо скажем. В спецификации сказано следующее:
If a method declaration d1 with return type R1 overrides or hides the declaration of another method d2 with return type R2, then d1 must be return-type substitutable for d2, or a compile-time error occurs.
Для меня лично понятие return-type substitutable включает возможность возврата byte
там, где ожидается int
. Однако компилятор так не считает, и внятного объяснения этому я в
спецификации пока что не нашел. Как говорил Семён Семёныч, будем искать...
Теперь обсудим...
Исключения
Тут ситуация следующая. В переопределенном методе можно:
- Вообще не описывать исключения – не использовать
throws
. Тогда код, который будет работать с родительским типом – будет требовать обработки исключения. Код, который работает с дочерним типом – нет. - Описать то же исключение, что и в родительском методе. Это очевидный вариант, и в комментариях вряд ли нуждается.
- Описать любое дочернее исключение по отношению к тому, которое бросается в родительском методе. Логика тут та же, что и в случае с сужением типа возвращаемого значения. Если код обрабатывает родительский тип исключения – он поймает и все дочерние. А тот код, который будет работать с дочерним типом – он может ловить именно то, что бросается в действительности. При грамотном использовании этой возможности код может стать гораздо более читаемым.
С переопределением методов связана еще одна потенциальная проблема –
Вызовы переопределенных методов из конструктора
Пусть у нас есть вот такой пример:
package test; /** * Overriden method call from constructor test * * @author Eugene Matyushkin */ public class OverridingTest{ public static class Parent{ public Parent(){ test(); } public void test(){ System.out.println("parent::test"); } } public static class Child extends Parent{ private String field; public Child(){ field = "abc"; } public void test(){ System.out.println("child::test"); System.out.println("field="+field); } } public static void main(String[] args){ new Child(); } }
Т.е. в конструкторе у родительского класса вызывается метод test
, который переопределяется в
дочернем классе. В результате выполнения этого кода мы имеем:
child::test field=null
Обратите внимание на вторую строку. Она и является индикатором наличия потенциальной проблемы. В данном
случае код, использующий переменную field
, был вызван раньше, чем эта переменная была
проинициализирована. Это не привело к фатальным последствиям, но представьте, что вместо простого вывода в
консоль значения вы бы захотели узнать длину строки. Вот вам и NullPointerException
.
Говоря более обще – вызовы из конструктора методов, которые возможно переопределить, потенциально опасны тем, что они вызываются в тот момент, когда объект еще не инициализирован до конца – не отработал код дочернего конструктора.
Вот мы и добрались до последней темы –
Связывание
Связывание (linking) – это процесс определения, какой именно метод надо вызывать. Различают два типа связывания: раннее, выполняемое на этапе компиляции, и позднее, выполняемое во время исполнения.
Раннее связывание выполняется для того, чтобы определить, какой именно метод надо вызывать, исходя из имени и набора его параметров. Иначе говоря, на этом этапе компилятор разбирается с перегрузкой.
Позднее связывание служит для того, чтобы разобраться с переопределением. Зная сигнатуру метода, виртуальная машина анализирует объект, на котором этот метод вызывается, чтобы определить, в каком именно классе брать определение вызываемого метода.
Хочу остановиться на таком моменте, как вызов статического метода. Поскольку статический метод – это метод класса, а не экземпляра, переопределить его нельзя. Запустим вот такой тест:
package test; /** * Static "Overriden" method call test * * @author Eugene Matyushkin */ public class StaticOverridingTest{ public static class Parent{ public void test(){ System.out.println("parent::test"); } public static void staticCall(){ System.out.println("static call parent"); } } public static class Child extends Parent{ public void test(){ System.out.println("child::test"); } public static void staticCall(){ System.out.println("static call child"); } } public static void main(String[] args){ Parent p = new Child(); p.staticCall(); p.test(); Child c = new Child(); c.staticCall(); c.test(); } }
У нас есть два объекта – формально родительский (p
) и дочерний (c
). Именно
формально, ибо создается в обоих случаях дочерний объект. И вот что мы получим в результате запуска теста:
static call parent child::test static call child child::test
Нестатический метод в обоих случаях вызвался дочерний, что неудивительно. А вот статический – этот метод соответствовал формальному типу того объекта, на котором он вызывался.
Хотя должен заметить, что вызов статического метода на объекте – дурной тон.
Теперь я хочу проиллюстрировать оба типа связывания в одном тесте.
package test; /** * Linkage test * * @author Eugene Matyushkin */ public class LinkageTest{ public static class Parent{ public void test(){ System.out.println("parent::test"); } } public static class Child extends Parent{ public void test(){ System.out.println("child::test"); } } public static class Tester{ public void test(Parent obj){ System.out.println("Testing parent..."); obj.test(); } public void test(Child obj){ System.out.println("Testing child..."); obj.test(); } } public static void main(String[] args){ Parent obj = new Child(); Tester t = new Tester(); t.test(obj); } }
Результатом выполнения будет:
Testing parent... child::test
Почему результат именно такой. Первое – раннее связывание вызова в методе main
–
t.test(obj)
. В классе Tester
есть метод, соответствующий формальному типу параметра
(Parent
). И именно поэтому компилятором выбирается он. Несмотря на то, что реальный тип –
Child
(однако узнать о реальном типе виртуальная машина сможет только в процессе исполнения). Дальше,
продолжая раннее связывание, – внутри метода test(Parent)
есть вызов метода test
на
переданном объекте. Поскольку у класса Parent
такой метод есть – компилятор ограничивается только
контролем.
Второе – этап выполнения. Ввиду раннего связывания на объекте t
вызывается метод
test(Parent)
. А вот перед вызовом obj.test()
выполняется позднее связывание – виртуальная
машина определяет, что реальный тип этого объекта – Child
, в результате чего вызывается метод,
определенный в классе Child
.
Разумеется, при использовании reflection можно добиться вызова метода test(Child)
вместо
test(Parent)
, поскольку во время исполнения мы всегда можем получить тип объекта p
и найти
метод с соответствующей сигнатурой.
Ну и напоследок – пару слов о final
-методах. Поскольку они не допускают переопределения –
позднее связывание для них бессмысленно. Возможно, где-то в недрах виртуальной машины и есть подобная
оптимизация, хотя в спецификации я не нашел никаких слов по этому поводу. А вызываются final
-методы
так же, как и обычные, с помощью инструкции виртуальной машины invokevirtual
.
* * *
На этом о переопределении и перегрузке методов, пожалуй, всё. Возможно, большая часть того, о чем я рассказывал, известна. Однако должен сказать, что приведенный выше тест постоянно давался на интервью в ValueCommerce и правильный результат выполнения называли очень немногие.