Перегрузка методов в Java: правила, выбор вызова и переопределение

Разработчик за ноутбуком с размытым кодом на экране Программирование и разработка

Перегрузка методов в Java (overloading) позволяет объявить в классе несколько методов с одинаковым именем, если у них разные списки параметров: другое количество, другие типы или другой порядок типов. Какой из перегруженных методов вызвать, компилятор определяет на этапе компиляции в зависимости от аргументов вызова, а переопределение (overriding) срабатывает во время выполнения по фактическому объекту.

Что такое перегрузка методов

В Java синтаксис перегрузки ничем не отличается от объявления обычного метода: пишем ещё один метод с тем же именем и другими параметрами. В JavaScript, для сравнения, одноимённые функции не перегружаются, и тип аргумента проверяют в теле через оператор typeof. В следующем примере у калькулятора три варианта метода sum. Без перегрузки пришлось бы придумывать имена sumInt, sumDouble, sumThree, а с ней вызывающему коду проще: имя одно, нужную версию выбирает компилятор. Примеры собраны и запущены на OpenJDK 21, вывод и тексты ошибок javac приведены дословно.

public class Calculator {
    static int sum(int a, int b) {
        return a + b;
    }

    static double sum(double a, double b) {
        return a + b;
    }

    static int sum(int a, int b, int c) {
        return a + b + c;
    }

    public static void main(String[] args) {
        System.out.println(sum(2, 3));
        System.out.println(sum(2.5, 0.5));
        System.out.println(sum(1, 2, 3));
        System.out.println(sum(2, 0.5));    // 2 расширяется до double
    }
}
5
3.0
6
2.5

Последний вызов интересен: метода sum(int, double) нет, поэтому компилятор расширил int до double и взял sum(double, double).

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

Что входит в сигнатуру метода

Сигнатура метода в Java состоит из имени метода и типов параметров в их порядке. Именно по ней компилятор отличает перегруженные методы. Что в сигнатуру не входит:

  • тип возвращаемого значения;
  • имена параметров;
  • модификаторы доступа (public, private) и static;
  • список исключений throws.

Поэтому два метода, которые различаются только типом возвращаемого значения, не компилируются:

public class Parser {
    int parse(String s) {
        return Integer.parseInt(s);
    }

    long parse(String s) {
        return Long.parseLong(s);
    }
}
Parser.java:6: error: method parse(String) is already defined in class Parser
    long parse(String s) {
         ^
1 error

Компилятор видит в вызове parse("42") только имя и аргументы, а тип результата в выборе не участвует. Та же ошибка method f(int) is already defined появится, если объявить void f(int a) и static void f(int b): другое имя параметра и static сигнатуру не меняют.

С дженериками есть своя ловушка: методы g(List<String>) и g(List<Integer>) выглядят разными, но после стирания типов оба становятся g(List), и javac отвечает name clash: g(List<Integer>) and g(List<String>) have the same erasure.

Перегрузка конструкторов

Конструкторы перегружаются по тем же правилам. Короткий конструктор обычно передаёт работу полному с помощью this(...), чтобы логика инициализации жила в одном месте и значения по умолчанию задавались в одной строке:

public class Rect {
    final int width, height;

    Rect() {
        this(1, 1);              // вызывает второй конструктор
    }

    Rect(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public static void main(String[] args) {
        System.out.println(new Rect().width);
        System.out.println(new Rect(3, 4).width);
    }
}
1
3

Доска со схемой из блоков, от одного блока стрелки к трём другим

Как Java выбирает перегруженный метод

Компилятор ищет подходящий метод в три фазы и останавливается на первой, где нашёл хотя бы один:

  1. Без автоупаковки и без varargs (аргументов переменной длины). Разрешено точное совпадение типа и расширение: byte до int, int до long или double, String до Object.
  2. С автоупаковкой и распаковкой (int в Integer и обратно), но ещё без varargs.
  3. С аргументами переменной длины.
Читайте также:  Async/await в JavaScript: как работает и где ошибаются

Вызов через varargs никогда не выбирается, если подходит метод с фиксированным числом параметров. Если на фазе подошло несколько методов, дальше берётся самый специфичный: тот, чьи параметры можно передать в остальные. В следующем классе видны первая и третья фазы, вторая появится, если убрать одну перегрузку:

public class Resolution {
    static void show(long x)    { System.out.println("show(long)"); }
    static void show(Integer x) { System.out.println("show(Integer)"); }
    static void show(int... x)  { System.out.println("show(int...)"); }

    public static void main(String[] args) {
        show(5);                  // фаза 1: расширение до long
        show(Integer.valueOf(5)); // фаза 1: точное совпадение
        show(5, 6);               // фаза 3: два аргумента принимает только varargs
    }
}
show(long)
show(Integer)
show(int...)

Для show(5) расширение до long победило упаковку, хотя обёртка кажется ближе по смыслу. Уберите версию с long, и первая строка выведет show(Integer). Уберите и её, тогда вызов уйдёт в show(int...). Внутри метода аргументы переменной длины приходят обычным массивом int[], который обходят циклом for.

Расширение и упаковку компилятор не совмещает. Метод save(Long id) с вызовом save(42) не скомпилируется с ошибкой incompatible types: int cannot be converted to Long. Литерал 42 имеет тип int, упаковать его можно только в Integer, а Integer не наследник Long. Помогает save(42L).

С null правило «самого специфичного» работает так: если есть print(Object) и print(String), вызов print(null) выведет print(String), потому что String наследуется от Object.

Разработчик указывает ручкой на строку размытого кода на мониторе

Неоднозначный вызов

Ошибка компиляции возникает, когда на одной фазе подходят два метода и ни один не специфичнее другого:

public class Ambiguous {
    static void pair(int a, long b) { System.out.println("int, long"); }
    static void pair(long a, int b) { System.out.println("long, int"); }

    public static void main(String[] args) {
        pair(1, 2);
    }
}
Ambiguous.java:6: error: reference to pair is ambiguous
        pair(1, 2);
        ^
  both method pair(int,long) in Ambiguous and method pair(long,int) in Ambiguous match
1 error

Решение: явный тип в вызове, pair(1, 2L) или pair(1L, 2). Та же ошибка будет с null, если перегрузки принимают два несвязанных ссылочных типа, например String и StringBuilder.

Ловушка List.remove

У List два метода с одинаковым именем: remove(int index) удаляет элемент по позиции, remove(Object o) удаляет значение. На List<Integer> это даёт ошибку, которую не видно глазами:

import java.util.ArrayList;
import java.util.List;

public class RemoveTrap {
    public static void main(String[] args) {
        List<Integer> ids = new ArrayList<>(List.of(10, 20, 30, 1));
        ids.remove(1);                  // remove(int index): удалён элемент с индексом 1
        System.out.println(ids);
        ids.remove(Integer.valueOf(1)); // remove(Object o): удалено значение 1
        System.out.println(ids);
    }
}
[10, 30, 1]
[10, 30]

Хотели удалить единицу, а удалили 20. Вызов remove(1) подходит к remove(int) уже на первой фазе, до автоупаковки дело не доходит. Значение удаляют через Integer.valueOf(1) или (Integer) 1.

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

Переопределение методов

Переопределение (overriding) нужно, когда подкласс меняет поведение метода родителя. У переопределяющего метода та же сигнатура, а вызывается он по фактическому типу объекта во время выполнения. На этом построен полиморфизм: переменная типа Animal может хранить Dog, и вызов sound() уйдёт в код собаки. Из методов класса Object обычно переопределяют toString(), equals() и hashCode(). На переопределении методов интерфейса держится и подмена реализаций, например порты и адаптеры в архитектурных паттернах.

Читайте также:  Список процессов Linux: как посмотреть запущенные процессы в терминале

Условия и ограничения при переопределении:

  • имя и типы параметров совпадают с родительским методом;
  • тип возвращаемого значения тот же, а для ссылочных типов можно вернуть подтип (ковариантный тип: родитель возвращает Animal, наследник Dog); для void и примитивов тип совпадает точно;
  • модификатор доступа не уже исходного: public остаётся public, protected можно расширить до public;
  • проверяемых исключений в throws не больше, чем у родительского метода;
  • методы final переопределить нельзя, а private-методы не наследуются, поэтому и не переопределяются;
  • static-метод не переопределяется, а скрывается: какой из двух сработает, зависит от типа, через который его вызвали, а @Override над ним даёт ошибку static methods cannot be annotated with @Override.

Если в родителе метод объявлен как public, а в наследнике его переопределили без модификатора, javac остановит сборку:

public class Narrow {
    static class Animal {
        public String sound() { return "..."; }
    }

    static class Cat extends Animal {
        @Override
        String sound() { return "мяу"; }
    }
}
Narrow.java:8: error: sound() in Cat cannot override sound() in Animal
        String sound() { return "мяу"; }
               ^
  attempting to assign weaker access privileges; was public
1 error

Аннотация @Override просит компилятор проверить, что метод действительно переопределяет метод родителя. Мы советуем ставить её всегда: без неё метод с опечаткой в типе параметра станет перегрузкой, и компилятор промолчит.

Перегрузка вместо переопределения: equals

Классическая ошибка на стыке двух механизмов: метод equals объявлен с параметром своего класса. Код компилируется, но это перегрузка, и Object.equals(Object) остаётся прежним:

import java.util.List;

public class EqualsTrap {
    static class Point {
        final int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        // перегрузка: Object.equals(Object) не переопределён
        public boolean equals(Point other) {
            return x == other.x && y == other.y;
        }
    }

    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);
        Object o = b;
        System.out.println(a.equals(b));
        System.out.println(a.equals(o));
        System.out.println(List.of(a).contains(b));
    }
}
true
false
false

Первый вызов попал в наш метод. Во втором у аргумента объявленный тип Object, и сработал equals(Object) класса Object, который сравнивает ссылки. Третий вызов делает коллекция, и результат тот же. Коллекции и HashMap вызывают equals(Object), поэтому точки с одинаковыми координатами в них считаются разными. Поставьте над методом @Override, и ошибка всплывёт при компиляции:

public class EqualsOverride {
    static class Point {
        final int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        @Override
        public boolean equals(Point other) {
            return x == other.x && y == other.y;
        }
    }
}
EqualsOverride.java:6: error: method does not override or implement a method from a supertype
        @Override
        ^
1 error

Правильная сигнатура: public boolean equals(Object other), и к ней в пару hashCode().

Два монитора на рабочем столе, на обоих размытый код в тёмном редакторе

Чем перегрузка отличается от переопределения

При перегрузке метод выбирает компилятор, а при переопределении JVM во время выполнения. В примере ниже оба механизма работают в одной строке:

public class Dispatch {
    static class Animal {
        String sound() { return "звук животного"; }
    }

    static class Dog extends Animal {
        @Override
        String sound() { return "гав"; }
    }

    static void describe(Animal a) { System.out.println("describe(Animal): " + a.sound()); }
    static void describe(Dog d)    { System.out.println("describe(Dog): " + d.sound()); }

    public static void main(String[] args) {
        Animal pet = new Dog();
        describe(pet);        // перегрузку выбрал компилятор по типу переменной
        describe(new Dog());
    }
}
describe(Animal): гав
describe(Dog): гав

При выборе перегрузки используется объявленный тип переменной. В pet лежит собака, но объявлена она как Animal, и компилятор выбрал describe(Animal). Внутри метода a.sound() уже отработал по фактическому объекту и вернул «гав».

Читайте также:  let и const в JavaScript: чем отличаются и что выбрать
Перегрузка Переопределение
Где в одном классе или в наследнике в наследнике или в классе, который реализует интерфейс
Параметры обязаны различаться совпадают
Возвращаемый тип любой тот же, для ссылочных типов можно подтип
Модификатор доступа любой не уже, чем в суперклассе
Когда выбирается метод на этапе компиляции во время выполнения
По чему выбирается объявленные типы аргументов фактический тип объекта
static, private, final перегружаются не переопределяются

Перегрузку иногда называют статическим полиморфизмом, а переопределение динамическим. Мы бы не называли перегрузку просто полиморфизмом без уточнения «на этапе компиляции»: в Java под полиморфизмом обычно понимают вызов переопределённого метода через ссылку на родителя.

Минусы перегрузки методов

Перегруженный код сложнее читать. Читатель видит remove(1) и должен в уме повторить три фазы выбора, чтобы понять, какой метод сработает. Мы бы не стали делать перегрузки с одинаковым числом параметров, если версии делают разное, а типы аргументов превращаются друг в друга: int и Object, int и Integer. Именно так устроен remove: одна версия удаляет по индексу, другая по значению. Здесь надёжнее разные имена: removeAt(int index) и removeValue(Object o).

Использовать перегрузку стоит, когда все версии решают одну задачу: тогда ошибка в выборе версии не меняет смысла вызова. Так устроен System.out.println: у него отдельные версии для int, double, char, String, Object и других типов, и каждая просто печатает значение. Если передать int, а версии для него бы не было, сработала бы версия с long, и на экране всё равно оказалось бы число. Безопасна и перегрузка с разным количеством параметров, как у sum в начале статьи: код чище, чем с набором похожих имён.

Блокнот с нарисованными от руки прямоугольниками и стрелками рядом с ноутбуком

Частые вопросы

Можно ли перегрузить метод только по типу возвращаемого значения?

Нет. Тип возвращаемого значения не входит в сигнатуру, и javac ответит method ... is already defined in class .... Чтобы методы различались, у них должны отличаться количество, типы или порядок параметров.

Можно ли перегрузить статический метод и main?

Да, статические методы перегружаются как обычные. Метод main тоже можно перегрузить, но программу JVM запускает через public static void main(String[] args). В классе с main(int count) и main(String[] args) сначала выполнится вторая версия, а первую можно вызвать из неё как обычный метод.

Влияют ли на перегрузку модификаторы доступа?

В сигнатуру модификаторы не входят, поэтому такой класс не скомпилируется:

public class Printer {
    public void print(int x) { }
    private void print(int x) { }
}
Printer.java:3: error: method print(int) is already defined in class Printer
    private void print(int x) { }
                 ^
1 error

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

Оцените статью
bestprogrammer.ru
Добавить комментарий