Статьи

4 Техники для написания лучшей Java

Изо дня в день большая часть 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) определяется как

  1. Если R 1 является недействительным, то R 2 является недействительным
  2. Если R 1 является примитивным типом, тогда R 2 идентичен R 1
  3. Если R 1 является ссылочным типом, то выполняется одно из следующих условий:

    1. R 1, адаптированный к типу параметров d 2, является подтипом R 2 .
    2. R 1 может быть преобразован в подтип R 2 путем непроверенного преобразования
    3. 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::clonebe 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настоящие абстрактный класс и интерфейс, а не реализующие как ):extendsWriterimplementsReader

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 .