Последнее изменение: 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 и правильный результат выполнения называли очень немногие.



skipy.ru)