Последнее изменение: 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. Однако компилятор так не считает, и внятного объяснения этому я в спецификации пока что не нашел. Как говорил Семён Семёныч, будем искать...

Теперь обсудим...

Исключения

Тут ситуация следующая. В переопределенном методе можно:

  1. Вообще не описывать исключения – не использовать throws. Тогда код, который будет работать с родительским типом – будет требовать обработки исключения. Код, который работает с дочерним типом – нет.
  2. Описать то же исключение, что и в родительском методе. Это очевидный вариант, и в комментариях вряд ли нуждается.
  3. Описать любое дочернее исключение по отношению к тому, которое бросается в родительском методе. Логика тут та же, что и в случае с сужением типа возвращаемого значения. Если код обрабатывает родительский тип исключения – он поймает и все дочерние. А тот код, который будет работать с дочерним типом – он может ловить именно то, что бросается в действительности. При грамотном использовании этой возможности код может стать гораздо более читаемым.

С переопределением методов связана еще одна потенциальная проблема –

Вызовы переопределенных методов из конструктора

Пусть у нас есть вот такой пример:

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