Изо дня в день большая часть Java, которую мы пишем, использует небольшую часть возможностей полного набора возможностей языка. Каждого, который Stream
мы создаем, и каждой @Autowired
аннотации, которую мы добавляем к переменным нашего экземпляра, достаточно для достижения большинства наших целей. Однако бывают случаи, когда мы должны прибегать к этим редко используемым частям языка: скрытым частям языка, которые служат определенной цели.
В этой статье рассматриваются четыре метода, которые можно использовать, когда они попадают в привязку, и вводить их в базу кода, чтобы повысить удобство разработки и удобочитаемость. Не все эти методы будут применимы в любой ситуации или даже в большинстве. Например, может быть только несколько методов, которые поддаются ковариантным типам возврата, или только несколько универсальных классов, которые соответствуют шаблону для использования пересеченных универсальных типов, в то время как другие, такие как конечные методы и классы и блоки try-with-resources , улучшит читабельность и понятность намерений большинства кодовых баз. В любом случае важно не только знать, что эти методы существуют, но и знать, когда их разумно применять.
1. Ковариантные типы возврата
Даже самая вводная книга с практическими рекомендациями по Java будет включать в себя страницы материалов о наследовании, интерфейсах, абстрактных классах и переопределении методов, но редко даже в продвинутых текстах исследуются более сложные возможности при переопределении метода. Например, следующий фрагмент не станет неожиданностью даже для самого начинающего Java-разработчика:
public interface Animal {
public String makeNoise();
}
public class Dog implements Animal {
@Override
public String makeNoise() {
return "Woof";
}
}
public class Cat implements Animal {
@Override
public String makeNoise() {
return "Meow";
}
}
Это фундаментальная концепция полиморфизма: метод объекта можно вызывать в соответствии с его интерфейсом ( Animal::makeNoise
), но фактическое поведение вызова метода зависит от типа реализации ( Dog::makeNoise
). Например, выходные данные следующего метода будут меняться в зависимости от того, передан ли Dog
объект или Cat
объект методу:
public class Talker {
public static void talk(Animal animal) {
System.out.println(animal.makeNoise());
}
}
Talker.talk(new Dog()); // Output: Woof
Talker.talk(new Cat()); // Output: Meow
Хотя этот метод обычно используется во многих Java-приложениях, при переопределении метода можно предпринять менее известное действие: изменить тип возвращаемого значения. Хотя может показаться, что это открытый способ переопределения метода, существуют некоторые серьезные ограничения на тип возвращаемого значения переопределенного метода. Согласно Спецификации языка Java 8 SE (стр. 248):
Если объявление метода d
1 с типом возврата R
1 переопределяет или скрывает объявление другого метода d
2 с типом возврата R
2 , то d
1 должен быть заменяемым типом возврата для d
2 , иначе произойдет ошибка времени компиляции.
где заменяемый тип возврата (там же, стр. 240) определяется как
- Если R 1 является недействительным, то R 2 является недействительным
- Если R 1 является примитивным типом, тогда R 2 идентичен R 1
- Если R 1 является ссылочным типом, то выполняется одно из следующих условий:
- R 1, адаптированный к типу параметров d 2, является подтипом R 2 .
- R 1 может быть преобразован в подтип R 2 путем непроверенного преобразования
- d 1 не имеет такой же сигнатуры, как d 2, и R 1 = | R 2 |
Возможно, самый интересный случай — это случай с Правилами 3.a. и 3.b .: при переопределении метода подтип возвращаемого типа может быть объявлен как переопределенный возвращаемый тип. Например:
public interface CustomCloneable {
public Object customClone();
}
public class Vehicle implements CustomCloneable {
private final String model;
public Vehicle(String model) {
this.model = model;
}
@Override
public Vehicle customClone() {
return new Vehicle(this.model);
}
public String getModel() {
return this.model;
}
}
Vehicle originalVehicle = new Vehicle("Corvette");
Vehicle clonedVehicle = originalVehicle.customClone();
System.out.println(clonedVehicle.getModel());
Хотя исходный тип возврата clone()
is Object
, мы можем вызывать getModel()
наш клонированный Vehicle
(без явного приведения), потому что мы переопределили возвращаемый тип Vehicle::clone
be Vehicle
. Это устраняет необходимость в беспорядочных приведениях, когда мы знаем, что искомый тип возвращаемого значения равен a Vehicle
, даже если он объявлен как Object
(что соответствует безопасному приведению на основе априорной информации, но строго говоря небезопасно):
Vehicle clonedVehicle = (Vehicle) originalVehicle.customClone();
Обратите внимание, что мы все еще можем объявить тип транспортного средства как a, Object
и тип возвращаемого значения вернется к своему первоначальному типу Object
:
Object clonedVehicle = originalVehicle.customClone();
System.out.println(clonedVehicle.getModel()); // ERROR: getModel not a method of Object
Обратите внимание, что возвращаемый тип не может быть перегружен по отношению к универсальному параметру, но это может быть по отношению к универсальному классу. Например, если базовый класс или метод интерфейса возвращает a List<Animal>
, возвращаемый тип подкласса может быть переопределен ArrayList<Animal>
, но он не может быть переопределен List<Dog>
.
2. Пересечение родовых типов
Создание универсального класса является отличным способом создания набора классов, которые аналогичным образом взаимодействуют с составными объектами. Например, a List<T>
просто хранит и получает объекты типа T
без понимания природы элементов, которые он содержит. В некоторых случаях мы хотим ограничить наш параметр универсального типа ( T
) определенными характеристиками. Например, учитывая следующий интерфейс
public interface Writer {
public void write();
}
Мы можем захотеть создать определенную коллекцию Writers
в соответствии с составным шаблоном :
public class WriterComposite<T extends Writer> implements Writer {
private final List<T> writers;
public WriterComposite(List<T> writers) {
this.writers = writers;
}
@Override
public void write() {
for (Writer writer: this.writers) {
writer.write();
}
}
}
Теперь мы можем пройтись по дереву Writers
, не зная, является ли конкретная вещь, с которой Writer
мы сталкиваемся, автономной Writer
(лист) или набором Writers
(составной). Что если бы мы также хотели, чтобы наш композит действовал как композит для читателей и писателей? Например, если у нас был следующий интерфейс
public interface Reader {
public void read();
}
Как мы могли бы изменить наш, WriterComposite
чтобы быть ReaderWriterComposite
? Одним из методов будет создание нового интерфейса ReaderWriter
, который объединяет Reader
и Writer
интерфейс:
public interface ReaderWriter extends Reader, Writer {}
Тогда мы можем изменить наш существующий, WriterComposite
чтобы быть следующим:
public class ReaderWriterComposite<T extends ReaderWriter> implements ReaderWriter {
private final List<T> readerWriters;
public ReaderWriterComposite(List<T> readerWriters) {
this.readerWriters = readerWriters;
}
@Override
public void write() {
for (Writer writer: this.readerWriters) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.readerWriters) {
reader.read();
}
}
}
Несмотря на то, что это достигло нашей цели, в нашем коде мы создали наворот: мы создали интерфейс с единственной целью объединения двух существующих интерфейсов. Со все большим количеством интерфейсов мы можем начать видеть комбинаторный взрыв вздутия. Например, если мы создаем новый Modifier
интерфейс, теперь мы должны были бы создать ReaderModifier
, WriterModifier
и ReaderWriter
интерфейсы. Обратите внимание, что эти интерфейсы не добавляют никакой функциональности: они просто объединяют существующие интерфейсы.
Чтобы удалить этот раздув, нам нужно было бы указать, что наш ReaderWriterComposite
принимает параметры универсального типа, если и только если они оба Reader
и Writer
. Перекрестные универсальные типы позволяют нам делать именно это. Чтобы указать, что параметр универсального типа должен реализовывать оба интерфейса Reader
и Writer
интерфейс, мы используем &
оператор между ограничениями универсального типа:
public class ReaderWriterComposite<T extends Reader & Writer> implements Reader, Writer {
private final List<T> readerWriters;
public WriterComposite(List<T> readerWriters) {
this.readerWriters = readerWriters;
}
@Override
public void write() {
for (Writer writer: this.readerWriters) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.readerWriters) {
reader.read();
}
}
}
Без раздувания нашего дерева наследования мы теперь можем ограничить наш параметр универсального типа для реализации нескольких интерфейсов. Обратите внимание, что такое же ограничение может быть указано, если один из интерфейсов является абстрактным классом или конкретным классом. Например, если мы изменили наш Writer
интерфейс в абстрактный класс, похожий на следующий
public abstract class Writer {
public abstract void write();
}
Мы можем ограничить наш общий параметр типа , чтобы быть как Reader
и А Writer
, но Writer
(так как он является абстрактным классом , а не интерфейс) должны быть указаны первым (также отметить , что наши ReaderWriterComposite
настоящие абстрактный класс и интерфейс, а не реализующие как ):extends
Writer
implements
Reader
public class ReaderWriterComposite<T extends Writer & Reader> extends Writer implements Reader {
// Same class body as before
}
Также важно отметить, что этот универсальный универсальный тип может использоваться для более чем двух интерфейсов (или одного абстрактного класса и более чем одного интерфейса). Например, если мы хотим, чтобы наш композит также включал Modifier
интерфейс, мы могли бы написать определение нашего класса следующим образом:
public class ReaderWriterComposite<T extends Reader & Writer & Modifier> implements Reader, Writer, Modifier {
private final List<T> things;
public ReaderWriterComposite(List<T> things) {
this.things = things;
}
@Override
public void write() {
for (Writer writer: this.things) {
writer.write();
}
}
@Override
public void read() {
for (Reader reader: this.things) {
reader.read();
}
}
@Override
public void modify() {
for (Modifier modifier: this.things) {
modifier.modify();
}
}
}
Хотя это и допустимо для выполнения вышесказанного, это может быть признаком запаха кода (объект, который является a Reader
, a Writer
и a Modifier
, вероятно, будет чем-то гораздо более конкретным, например a File
).
Для получения дополнительной информации о пересеченных универсальных типах см. Спецификацию языка Java 8 .
3. Автоматически закрываемые классы
Создание класса ресурса является обычной практикой, но поддержание целостности этого ресурса может быть сложной перспективой, особенно когда речь идет об обработке исключений. Например, предположим, что мы создали класс ресурса Resource
и хотим выполнить на этом ресурсе действие, которое может вызвать исключение (процесс создания экземпляра также может вызвать исключение):
public class Resource {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
}
public void close() {
System.out.println("Closed resource");
}
}
В любом случае (если исключение выброшено или не выброшено), мы хотим закрыть наш ресурс, чтобы убедиться в отсутствии утечек ресурса. Обычный процесс заключается в том, чтобы заключить наш close()
метод в finally
блок, гарантируя, что независимо от того, что произойдет, наш ресурс будет закрыт до того, как будет завершена закрытая область выполнения:
Resource resource = null;
try {
resource = new Resource();
resource.someAction();
}
catch (Exception e) {
System.out.println("Exception caught");
}
finally {
resource.close();
}
При простом осмотре, есть много стандартного кода, который ухудшает читаемость исполнения someAction()
нашего Resource
объекта. Чтобы исправить эту ситуацию, в Java 7 был введен оператор try-with-resources , в результате чего ресурс может быть создан в try
операторе и автоматически закрыт до того, как try
будет оставлена область выполнения. Чтобы класс мог использовать try-with-resources, он должен реализовать AutoCloseable
интерфейс:
public class Resource implements AutoCloseable {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
}
@Override
public void close() {
System.out.println("Closed resource");
}
}
Теперь, когда наш Resource
класс реализует AutoCloseable
интерфейс, мы можем очистить наш код, чтобы убедиться, что наш ресурс закрыт перед выходом из области выполнения try:
try (Resource resource = new Resource()) {
resource.someAction();
}
catch (Exception e) {
System.out.println("Exception caught");
}
По сравнению с техникой «не пробуй с ресурсами», этот процесс намного менее загроможден и поддерживает ту же безопасность (ресурс всегда закрывается после завершения области try
выполнения). Если приведенный выше оператор try-with-resources выполняется, мы получаем следующий вывод:
Created resource
Performed some action
Closed resource
Чтобы продемонстрировать безопасность этого метода try-with-resources, мы можем изменить наш someAction()
метод, чтобы вывести Exception
:
public class Resource implements AutoCloseable {
public Resource() throws Exception {
System.out.println("Created resource");
}
public void someAction() throws Exception {
System.out.println("Performed some action");
throw new Exception();
}
@Override
public void close() {
System.out.println("Closed resource");
}
}
Если мы снова запустим оператор try-with-resources, мы получим следующий вывод:
Created resource
Performed some action
Closed resource
Exception caught
Обратите внимание , что даже при том , что Exception
был брошен во время выполнения someAction()
метода, наш ресурс был закрыт , а затемException
был пойман. Это гарантирует, что до выхода из области try
выполнения наш ресурс гарантированно будет закрыт. Также важно отметить, что ресурс может реализовать Closeable
интерфейс и все еще использовать оператор try-with-resources. Разница между реализацией AutoCloseable
интерфейса и Closeable
интерфейса зависит от типа исключения, сгенерированного из close()
сигнатуры метода: Exception
и IOException
, соответственно. В нашем случае мы просто изменили сигнатуру close()
метода, чтобы не создавать исключение.
4. Финальные классы и методы
Почти во всех случаях создаваемые нами классы могут быть расширены другим разработчиком и настроены в соответствии с потребностями этого разработчика (мы можем расширять наши собственные классы), даже если мы не стремились расширять наши классы. Хотя этого достаточно для большинства случаев, могут быть случаи, когда мы не хотим, чтобы метод был переопределен, или, в более общем случае, расширяем один из наших классов. Например, если мы создаем File
класс, который инкапсулирует чтение и запись файла в файловой системе, мы можем не захотеть, чтобы какие-либо подклассы переопределяли наши read(int bytes)
и write(String data)
методы (если логика в этих методах изменена, это может привести к тому, что файловая система испортиться). В этом случае мы помечаем наши нерасширяемые методы как final
:
public class File {
public final String read(int bytes) {
// Execute the read on the file system
return "Some read data";
}
public final void write(String data) {
// Execute the write to the file system
}
}
Теперь, если другой класс хочет отменить либо чтение или методы записи, выбрасываются ошибка компиляции: Cannot override the final method from File
. Мы не только задокументировали, что наши методы не должны быть переопределены, но и компилятор также обеспечил реализацию этого намерения во время компиляции.
Распространяя эту идею на весь класс, могут быть ситуации, когда мы не хотим, чтобы создаваемый нами класс был расширен. Это не только делает каждый метод нашего класса нерасширяемым, но также обеспечивает невозможность создания подтипа нашего класса. Например, если мы создаем инфраструктуру безопасности, которая использует генератор ключей, мы можем не захотеть, чтобы какой-либо сторонний разработчик расширил наш генератор ключей и переопределил алгоритм генерации (пользовательские функции могут быть криптографически неполноценными и поставить под угрозу систему):
public final class KeyGenerator {
private final String seed;
public KeyGenerator(String seed) {
this.seed = seed;
}
public CryptographicKey generate() {
// ...Do some cryptographic work to generate the key...
}
}
Создавая наш KeyGenerator
класс final
, компилятор гарантирует, что ни один класс не сможет расширить наш класс и передать себя в нашу инфраструктуру в качестве действующего генератора криптографических ключей. Хотя может показаться, что достаточно просто пометить generate()
метод как final
, это не мешает разработчику создавать собственный генератор ключей и выдавать его за действительный генератор. Поскольку наша система ориентирована на безопасность, хорошей идеей является недоверие к внешнему миру, насколько это возможно (умный разработчик может изменить алгоритм генерации, изменив функциональность других методов в KeyGenerator
классе, если эти методы подарок).
Хотя это выглядит вопиющим пренебрежением к Открытому / Закрытому Принципу (и так оно и есть), есть веская причина для этого. Как видно из нашего примера безопасности выше, во многих случаях мы не можем позволить себе позволить внешнему миру делать то, что он хочет, с нашим приложением, и мы должны быть очень осмотрительными при принятии решения о наследовании. Такие писатели, как Джош Болч, даже зашли так далеко, что сказали, что класс должен быть специально спроектирован для расширения или же он должен быть явно закрыт для расширения ( Effective Java ). Хотя он намеренно переоценил эту идею (см. « Документирование наследования или запрет на его использование»).), он подчеркивает: мы должны очень тщательно продумать, какие из наших классов должны быть расширены, а какие из наших методов открыты для переопределения.
Заключение
Хотя большая часть кода, который мы пишем, использует лишь небольшую часть возможностей Java, этого достаточно для решения большинства проблем, с которыми мы сталкиваемся. Однако бывают случаи, когда нам нужно копнуть немного глубже в язык и отряхнуть те забытые или неизвестные части языка, чтобы решить конкретную проблему. Некоторые из этих методов, такие как ковариантные возвращаемые типы и универсальные универсальные типы, могут использоваться в одноразовых ситуациях, в то время как другие, такие как автоматически закрываемые ресурсы и конечные методы и классы, могут и должны использоваться чаще для получения более читаемых и более точный код. Сочетание этих методов с ежедневными практиками программирования помогает не только лучше понять наши намерения, но и лучше, более хорошо написанную Java.
Исходный код этой статьи можно найти на GitHub .