Исключения при наследовании в Java: переопределение методов и конструкторы
Пишете class FileTask implements Runnable, внутри run() читаете файл, добавляете к методу throws IOException — и код не компилируется: run() in FileTask cannot implement run() in Runnable; overridden method does not throw IOException. Компилятор не придирается: так работает правило исключений при наследовании.
Исключения при наследовании в Java подчиняются двум правилам для проверяемых (checked) исключений. Метод, переопределяющий метод суперкласса или интерфейса, не может объявлять в throws проверяемые исключения, которые выше по иерархии, чем исключения в методе суперкласса, или не связаны с ними. Конструктор подкласса, наоборот, обязан объявить все проверяемые исключения вызываемого конструктора суперкласса (или их суперклассы). На непроверяемые исключения эти правила не распространяются.
Два правила исключений при наследовании
При наследовании компилятор проверяет блок throws у переопределяющих методов и у конструкторов подкласса. Метод подкласса может объявить те же проверяемые исключения, что и метод суперкласса, их подклассы или ничего. Конструктор подкласса должен объявить все проверяемые исключения конструктора суперкласса, который он вызывает, или их суперклассы.
- Переопределение метода. Переопределяемый метод в подклассе не может выбрасывать проверяемые исключения, которые выше по иерархии, чем исключения в методе суперкласса, а также новые проверяемые исключения, не связанные с ними.
- Конструктор. Конструктор подкласса должен включить в свой блок
throwsвсе классы исключений или их суперклассы из блокаthrowsконструктора суперкласса, к которому он обращается при создании объекта.
Что такое проверяемые и непроверяемые исключения, разобрано в уроке Иерархия исключений в Java, синтаксис объявления — в уроке Оператор throws. Оба правила в одном примере:
import java.io.IOException;
public class SuperClass {
public SuperClass() throws IOException {
}
public void start() throws IOException {
}
} import java.io.FileNotFoundException;
import java.io.IOException;
public class SubClass extends SuperClass {
// конструктор обязан объявить IOException из конструктора SuperClass;
// ArithmeticException - непроверяемое, его можно указывать свободно
public SubClass() throws IOException, ArithmeticException {
}
// FileNotFoundException - подкласс IOException, сужение разрешено
@Override
public void start() throws FileNotFoundException {
}
} Обратите внимание на асимметрию: метод подкласса может сузить список исключений, а конструктор подкласса может его только сохранить или расширить.
Переопределение метода: throws не может расширяться
Переопределяющий метод в Java может объявить в throws только те проверяемые исключения, что объявлены у метода суперкласса, или их подклассы; может убрать throws совсем и может добавить любые непроверяемые исключения. Более общий (Exception) или посторонний (SQLException) проверяемый тип — ошибка компиляции.
Варианты для метода суперкласса public void start() throws IOException:
| throws в методе подкласса | Компилируется? | Почему |
|---|---|---|
throws IOException | Да | Тот же тип |
throws FileNotFoundException, EOFException | Да | Оба — подклассы IOException |
без throws | Да | Метод обещает меньше, чем родитель |
throws IllegalArgumentException | Да | Непроверяемое исключение, правило на него не действует |
throws Exception | Нет | Суперкласс IOException — выше по иерархии |
throws SQLException | Нет | Новое проверяемое исключение, не связанное с IOException |
При попытке объявить throws Exception javac сообщает:
error: start() in SubClass cannot override start() in SuperClass
public void start() throws Exception {
^
overridden method does not throw Exception Зачем нужно это правило
Правило защищает полиморфный код. Метод ниже работает с любым наследником SuperClass и, глядя на сигнатуру start(), перехватывает только IOException:
static void launch(SuperClass obj) {
try {
obj.start();
} catch (IOException e) {
System.out.println("Обработано: " + e);
}
} Если бы наследник мог выбросить из start() SQLException, оно прошло бы мимо этого catch, хотя компилятор гарантировал, что все проверяемые исключения обработаны. Поэтому подкласс может обещать меньше, чем родитель, но не больше.
Компилятор смотрит на тип ссылки, а не на объект
Какие исключения нужно обработать, компилятор определяет по объявленному типу переменной. Один и тот же объект требует try-catch при вызове через ссылку на родителя и не требует через ссылку на наследника, который убрал throws:
import java.io.IOException;
class Parser {
void parse(String s) throws IOException {
}
}
class StrictParser extends Parser {
@Override
void parse(String s) { // throws убран - это разрешено
System.out.println("Разбираю: " + s);
}
}
public class StaticTypeDemo {
public static void main(String[] args) {
StrictParser strict = new StrictParser();
strict.parse("a"); // компилируется без try-catch
Parser p = strict; // тот же объект
try {
p.parse("b"); // а здесь catch IOException обязателен
} catch (IOException e) {
System.out.println("Не произойдёт");
}
}
} Разбираю: a
Разбираю: b Запомните
Проверка throws — работа компилятора, а не JVM. Во время выполнения вызывается метод реального объекта, но решение «нужен ли здесь catch» принимается заранее по типу ссылки. Поэтому сузить throws в подклассе полезно: код, который работает с конкретным классом, избавляется от лишних try-catch.
Реализация интерфейсов: Runnable, AutoCloseable и два интерфейса сразу
Правило переопределения действует и при реализации методов интерфейса: метод класса не может объявить проверяемое исключение, которого нет в throws метода интерфейса. Отсюда частые на практике ситуации: в Runnable.run(), Comparator.compare() или Object.toString() нельзя объявить IOException, потому что исходные методы не объявляют ничего.
Проверяемое исключение внутри run()
Если исключение нельзя объявить, его обрабатывают на месте или оборачивают в непроверяемое, сохраняя причину. Для ошибок ввода-вывода в JDK есть готовый класс UncheckedIOException (Java 8+):
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class FileTask implements Runnable {
private final Path path;
public FileTask(Path path) {
this.path = path;
}
@Override
public void run() { // throws IOException здесь - ошибка компиляции
try {
System.out.println(Files.readString(path));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static void main(String[] args) {
new FileTask(Path.of("missing.txt")).run();
}
} Вывод, если файла нет (фрагмент):
Exception in thread "main" java.io.UncheckedIOException: java.nio.file.NoSuchFileException: missing.txt
at FileTask.run(FileTask.java:18)
at FileTask.main(FileTask.java:23)
Caused by: java.nio.file.NoSuchFileException: missing.txt
... Сужение throws у AutoCloseable.close()
Метод AutoCloseable.close() объявлен как throws Exception. Если ваш ресурс при закрытии не выбрасывает проверяемых исключений, уберите throws в реализации — и try-with-resources с этим классом перестанет требовать catch (Exception e):
public class DbSession implements AutoCloseable {
@Override
public void close() { // без throws Exception
System.out.println("Сессия закрыта");
}
public static void main(String[] args) {
try (DbSession s = new DbSession()) {
System.out.println("Работаем");
} // catch не нужен
}
} Работаем
Сессия закрыта Если бы в DbSession оставили сигнатуру close() throws Exception, тот же main не скомпилировался бы: unreported exception Exception; must be caught or declared to be thrown.
Скомпилируется ли: один метод из двух интерфейсов
Неочевидный случай: класс реализует два интерфейса с одинаковым методом, но разными throws. Метод класса переопределяет оба сразу, поэтому может объявить только исключения, разрешённые обоими интерфейсами:
import java.io.IOException;
import java.sql.SQLException;
interface FileSource {
void open() throws IOException;
}
interface DbSource {
void open() throws SQLException;
}
class HybridSource implements FileSource, DbSource {
@Override
public void open() throws IOException { // ошибка компиляции
}
} error: open() in HybridSource cannot implement open() in DbSource
overridden method does not throw IOException IOException и SQLException не связаны наследованием, общих разрешённых проверяемых типов у них нет, поэтому HybridSource.open() может объявить только непроверяемые исключения или не объявлять ничего.
Конструктор подкласса и исключения конструктора суперкласса
Конструктор подкласса всегда вызывает конструктор суперкласса — явно через super(...) или неявно, поэтому все проверяемые исключения этого конструктора становятся исключениями конструктора подкласса. Их нужно объявить в throws тем же типом или суперклассом; дополнительные проверяемые исключения конструктор подкласса добавлять может.
Конструкторы не наследуются и не переопределяются: вызывающий код пишет new SubClass() и видит throws именно этого конструктора. Поэтому ограничение «не больше, чем у родителя» здесь не нужно, а правило работает в обратную сторону.
Варианты для конструктора суперкласса public SuperClass() throws IOException:
| Конструктор подкласса | Компилируется? | Почему |
|---|---|---|
SubClass() throws IOException | Да | Тот же тип |
SubClass() throws Exception | Да | Суперкласс IOException покрывает его |
SubClass() throws IOException, SQLException | Да | Новые проверяемые исключения добавлять можно |
SubClass() throws FileNotFoundException | Нет | Подкласс не покрывает все случаи IOException |
SubClass() без throws | Нет | unreported exception IOException; must be caught or declared to be thrown |
| Конструктор не написан | Нет | Конструктор по умолчанию не объявляет исключений: unreported exception IOException in default constructor |
Перехватить исключение конструктора суперкласса внутри конструктора подкласса нельзя: вызов super(...) не может стоять внутри блока try. Это верно и для Java 25, где перед super(...) разрешены другие инструкции (JEP 513, Flexible Constructor Bodies): сам вызов остаётся отдельной инструкцией верхнего уровня. Если исключение нужно преобразовать, это делают в фабричном методе, который вызывает конструктор.
Что происходит с объектом, если конструктор выбросил исключение
Если конструктор завершился исключением, выражение new не возвращает ссылку, и переменная сохраняет прежнее значение — например, null. Объект, под который JVM успела выделить память, недоступен программе и будет удалён сборщиком мусора. Поэтому после перехвата такого исключения обращаться к переменной без проверки нельзя.
public class ConstructorException {
private int i;
public ConstructorException(int i) {
this.i = 20 / i; // при i == 0 - ArithmeticException
}
public int getI() {
return i;
}
} public class ExceptionInConstructorExample {
public static void main(String[] args) {
ConstructorException p = null;
try {
p = new ConstructorException(0);
} catch (ArithmeticException e) {
System.out.println("Конструктор выбросил: " + e.getMessage());
}
if (p != null) {
System.out.println("i = " + p.getI());
} else {
System.out.println("Объект не создан, p == null");
}
}
} Конструктор выбросил: / by zero
Объект не создан, p == null Частая ошибка — «погасить» исключение конструктора и сразу вызвать p.getI() без проверки. Исключение из конструктора перехвачено, но p осталась null, и программа всё равно падает, только уже с NullPointerException (в Java 14+ с сообщением вида Cannot invoke "ConstructorException.getI()"). Надёжнее либо проверять ссылку, либо помещать весь код, которому нужен объект, внутрь того же блока try.
Часто задаваемые вопросы
Действуют ли правила throws для статических методов, которые нельзя переопределить?
Да. Статический метод подкласса с той же сигнатурой не переопределяет, а скрывает (hiding) метод суперкласса, но спецификация Java применяет к нему то же ограничение: объявить более общее или новое проверяемое исключение нельзя, будет ошибка компиляции.
Наследник убрал throws, почему компилятор всё равно требует catch?
Потому что вызов идёт через ссылку типа суперкласса или интерфейса, а компилятор проверяет исключения по объявленному типу переменной, а не по реальному объекту. Объявите переменную типом наследника — и try-catch станет не нужен. Пример с выводом — в разделе Переопределение метода.
Как выбросить IOException из метода, который не объявляет throws, например из лямбды или toString()?
Обернуть его в непроверяемое исключение с сохранением причины: throw new UncheckedIOException(e) для IOException или throw new RuntimeException(e) для остальных. Заглушать исключение пустым catch не стоит: ошибка пропадёт бесследно. То же касается лямбд для forEach и Stream API: Consumer.accept() не объявляет проверяемых исключений.
Можно ли в конструкторе подкласса объявить меньше исключений, если перехватить их в своём коде?
Нет, если исключение объявлено у вызываемого конструктора суперкласса: вызов super(...) нельзя поместить в try, поэтому его исключения обязательно попадают в throws конструктора подкласса. Выход — вызвать другой конструктор суперкласса, который этих исключений не объявляет, или создавать объект через фабричный метод, который сам перехватит исключение.
Comments