Классы и интерфейсы

Пункт 15 «Сведите к минимуму доступность классов и членов»

Хорошо спроектированный компонент скрывает все детали своей реализации и отделяет свой API от своей реализации, поэтому компоненты взаимодействуют только через API.

Эта концепция известна как Инкапсуляция. Она важна, поскольку разделяет компоненты системы, обеспечивает параллельную разработку и позволяет оптимизировать или изменять компоненты, не затрагивая другие компоненты. Правило состоит в том, чтобы «сделать каждый класс или член максимально недоступным».

Для классов высшего уровня:

Существует только два уровня доступа (частный пакет и общедоступный), если класс верхнего уровня может быть закрытым для пакета, сделайте это, поскольку таким образом вы делаете его частью реализации, а не частью общедоступного API (должен поддерживаться навсегда ).

Наконец, если класс верхнего уровня используется только одним классом, подумайте о том, чтобы сделать его закрытым статическим вложенным классом единственного класса, который его использует.

Для членов (полей, методов, вложенных классов):

Существует четыре возможных уровня доступа (частный, по умолчанию, защищенный и общедоступный).

При проектировании вашего класса подумайте о том, чтобы сделать все члены закрытыми, только если классу в том же пакете действительно нужен доступ к члену, сделайте его по умолчанию, если вы часто это делали, подумайте о редизайне и размышлении о декомпозиции.

Существуют ограничения на объем помощи по внедрению, которую вы можете предоставить с помощью методов по умолчанию. Хотя многие интерфейсы определяют поведение методов объекта, таких как equals и hashCode, вам не разрешается предоставлять для них методы по умолчанию.

Однако вы можете объединить преимущества интерфейсов и абстрактных классов, предоставив абстрактный каркасный класс реализации для интерфейса. Интерфейс определяет тип, возможно, предоставляя некоторые методы по умолчанию, в то время как скелетный класс реализации реализует остальные не примитивные методы интерфейса. Это шаблон метода шаблона.Поля экземпляров общедоступных классов редко должны быть общедоступными, если поле экземпляра не является окончательным или ссылается на изменяемый объект, то, сделав его общедоступным, оно не будет потокобезопасный.

Например

public static final Thing[] values = {...}

Это потенциальная дыра в безопасности, так как клиенты смогут изменять содержимое этого массива, вместо того, чтобы делать его общедоступным, вы можете открыть методы для получения из него или вернуть его клон.

Пункт 16 «В публичных классах используйте методы доступа, а не публичные поля»

Пример

Class Point {
    public double x;
    public double y;
}

Если общедоступный класс предоставляет свои поля данных, он теряет инкапсуляцию, поскольку теперь он является частью API, его следует заменить методами доступа (мутаторами), чтобы сохранить гибкость для изменения внутреннего представления.

Пример

Class Point {
    private  double x;
    private double x;
    public Point (double x, double y) {
        this.x = x;
        this.y = y;
    }
// setters ...
// getters ...
}

Но для неизменяемых полей или частного вложенного класса или частного вложенного класса нет ничего плохого в их раскрытии.

Пункт 17 «Свести к минимуму изменчивость»

Неизменяемые классы потокобезопасны, они не могут быть повреждены при многопоточном доступе, поэтому ими можно свободно делиться. Поэтому рекомендуется по возможности повторно использовать существующие объекты (с помощью статических фабрик).

Чтобы сделать класс неизменяемым, следуйте этим правилам:

  • Не предоставляйте методы, которые изменяют состояние объекта.
  • Убедитесь, что класс нельзя расширить.
  • Сделайте все поля приватными и окончательными.
  • обеспечить эксклюзивный доступ к любому изменяемому полю в классе.

Пример

public final class Complex {
    private final double re;
    private final double im;
    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
    public double realPart() { return re; }
    public double imaginaryPart() { return im; }
    public Complex plus(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }
....
}

Основным недостатком неизменяемых классов является то, что для каждого отдельного значения требуется отдельный объект, что является дорогостоящим процессом, особенно если вы выполняете многоэтапную операцию, которая генерирует новый объект на каждом этапе. Чтобы решить эту проблему, попробуйте угадать, какие многошаговые операции необходимы, и предоставьте их в виде примитивов или предоставьте общедоступный изменяемый класс-компаньон (например, для класса String его изменяемым компаньоном является класс StringBuilder).

Пункт 18 «Предпочитайте композицию наследованию»

Наследование нарушает инкапсуляцию, поскольку связывает подкласс с деталями реализации суперкласса, и любое изменение в коде суперкласса может нарушить работу подкласса.

Например

// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}

запуск этого кода

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));

Мы ожидаем, что метод getAddCount вернет три на этом этапе, но внутри он возвращает шесть, HashSet addAll реализуется поверх метода add.

Мы могли бы «исправить» подкласс, исключив его переопределение метода addAll. Хотя результирующий класс будет работать, это будет зависеть от того факта, что метод addAll в HashSet реализован поверх его add метод. Это «самостоятельное использование» является деталью реализации, и не гарантируется, что она сохранится во всех реализациях от выпуска к выпуску.

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

Например

public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    public InstrumentedSet(Set<E> s) {
        super(s);
    }
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}
// forwarding class
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) { this.s = s; }
    public boolean add(E e) { return s.add(e); }
    public boolean addAll(Collection<? extends E> c){ 
        return s.addAll(c);
    }
....
}

Наследование уместно только в тех случаях, когда подкласс действительно является подтипом суперкласса. Другими словами, класс B должен расширять класс A только в том случае, если между двумя классами существует отношение «есть-а». Если вам хочется, чтобы класс B расширял класс A, задайте себе вопрос: каждый ли B действительно является A?

Пункт 19 «Оформить и оформить на наследство или иным образом запретить его»

Пункт 18 предупредил вас об опасности создания подкласса «чужого» класса, который не был разработан и задокументирован для наследования.

Во-первых, класс должен задокументировать собственное использование переопределяемых методов. (Переопределяемый, неокончательный, общедоступный или защищенный.)

Например

In the java.util.AbstractCollection:
public boolean remove(Object o)
Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element e such that Objects.equals(o, e), if this collection contains one or more such elements. Returns true if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).
Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s remove method. Note that this implementation throws an UnsupportedOperationException if the iterator returned by this collection’s iterator method does not implement the remove method and this collection contains the specified object.

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

Например

public class Super {
    public Super() {
        overrideMe();
    }
    public void overrideMe() {
    }
}
public final class Sub extends Super {
    private final Instant instant;
    Sub() {
        Super();
        instant = Instant.now();
    }
    @Override public void overrideMe() {
        System.out.println(instant);
    }
}

Лучшее решение этой проблемы — запретить создание подклассов в классах, которые
не разработаны и не задокументированы для безопасного создания подклассов. Есть два способа
запретить создание подклассов. Проще всего объявить класс final. Альтернатива
состоит в том, чтобы сделать все конструкторы частными или пакетно-частными и добавить в конструкторы общедоступные статические фабрики.

Правило 20 «Предпочитайте интерфейсы абстрактным классам»

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

Зачем предпочитать интерфейсы абстрактным классам?

  1. Существующие классы можно легко модифицировать для реализации нового интерфейса. Просто добавьте необходимые методы, если они еще не существуют, и внедрите предложение в объявление класса.
    Существующие классы нельзя модифицировать для расширения нового абстрактного класса. Если вы хотите, чтобы два класса расширяли один и тот же абстрактный класс, вы должны поместить его высоко в иерархии типов, где он является предком обоих классов. К сожалению, это может нанести большой побочный ущерб иерархии типов, заставив всех потомков нового абстрактного класса создать его подкласс, независимо от того, подходит он или нет.
  2. Интерфейсы идеально подходят для определения примесей. Примесь — это тип, который класс может реализовать в дополнение к своему «основному типу», чтобы объявить, что он обеспечивает некоторое дополнительное поведение. Например, Comparable — это интерфейс миксина, который позволяет классу объявлять, что его экземпляры упорядочены по отношению к другим взаимно сопоставимым объектам.
    Абстрактные классы нельзя использовать для определения примесей по той же причине, по которой они не могут быть модифицированы для существующих классов: у класса не может быть более одного родителя, и в иерархии классов нет разумного места для вставки. миксин.
  3. Интерфейсы позволяют создавать неиерархические структуры типов. Иерархии типов отлично подходят для организации некоторых вещей, но другие вещи не попадают четко в жесткую иерархию. Например, предположим, что у нас есть интерфейс, представляющий певца, а другой — автора песен. В реальной жизни некоторые певцы также являются авторами песен. Поскольку для определения этих типов мы использовали интерфейсы, а не абстрактные классы, для одного класса вполне допустимо реализовать и Singer, и Songwriter. Альтернативой является раздутая иерархия классов, содержащая отдельный класс для каждой поддерживаемой комбинации атрибутов.

Существуют ограничения на объем помощи по внедрению, которую вы можете предоставить
с помощью методов по умолчанию. Хотя многие интерфейсы определяют поведение методов объекта, таких как equals и hashCode, вам не разрешено указывать значения по умолчанию. методы для них.

Однако вы можете объединить преимущества интерфейсов и абстрактных классов, предоставив абстрактный базовый класс реализации для работы с интерфейсом. Интерфейс определяет тип, возможно, предоставляя некоторые методы по умолчанию, в то время как скелетный класс реализации реализует остальные не примитивные методы интерфейса. Это шаблон Template
Method.

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

Написание скелетной реализации — относительно простой, хотя и несколько утомительный процесс. Во-первых, изучите интерфейс и решите, какие методы являются примитивами, с помощью которых могут быть реализованы остальные. Эти примитивы будут абстрактными методами в вашей скелетной реализации. Затем предоставьте в интерфейсе методы по умолчанию для всех методов, которые могут быть реализованы непосредственно поверх примитивов, но помните, что вы не можете предоставлять методы по умолчанию для методов объекта, таких как equals и хэш-код. Если примитивы и методы по умолчанию охватывают интерфейс, все готово, и класс скелетной реализации вам не нужен. В противном случае напишите класс, объявленный для реализации интерфейса, с реализациями всех остальных методов интерфейса. Класс может содержать любые закрытые поля и методы, соответствующие задаче.

Пункт 21 «Проектирование интерфейсов для потомков»

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

Например, рассмотрим метод removeIf, который был добавлен в интерфейс
Collection в Java 8. Этот метод удаляет все элементы, для которых заданная логическая функция (или предикат) возвращает значение true.

// Default method added to the Collection interface in Java 8
default boolean removeIf(Predicate<? super E> filter) {
    Objects.requireNonNull(filter);
    boolean result = false;
    for (Iterator<E> it = iterator(); it.hasNext(); ) {
        if (filter.test(it.next())) {
            it.remove();
            result = true;
        }
    }
    return result;
}

Эта реализация дает сбой в некоторых реальных реализациях Collection. Например, рассмотрим org.apache.commons.collections4.collection.-SynchronizedCollection. Этот класс из Apache Commons обеспечивает возможность того, что все методы синхронизируются с блокирующим объектом перед делегированием в обернутую коллекцию.

Класс Apache SynchronizedCollection все еще активно поддерживается, но на момент написания этой статьи он не переопределяет класс removeIf. метод. Если этот
класс используется в сочетании с Java 8, он унаследует реализацию по умолчанию removeIf, которая на самом деле не поддерживает основное обещание класса: автоматически синхронизировать при каждом вызове метода.

Чтобы этого не произошло в аналогичных реализациях библиотек платформы Java
, таких как класс package-private, возвращаемый Collections.synchronizedCollection, пришлось переопределить значение по умолчанию removeIf и другие подобные методы для выполнения необходимой синхронизации перед вызовом реализации по умолчанию.

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

Правило 22 «Используйте интерфейсы только для определения типов»

Когда класс реализует интерфейс, интерфейс должен что-то говорить о том, что клиент может делать с экземплярами класса.

Примером неправильного использования интерфейсов является постоянный интерфейс, интерфейс не содержит методов, а только статические конечные поля, как реализация постоянного интерфейса вызывает утечку этой детали реализации в экспортируемый API класса.

public interface Math {
    static final double PI = 3.141592653589793;
}

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

Пункт 23 «Предпочитайте наследование классов помеченным классам»

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

Например

class Figure {
    enum Shape { RECTANGLE, CIRCLE };
    final Shape shape;
    // These fields are used only if shape is RECTANGLE
    double length;
    double width;
    // This field is used only if shape is CIRCLE
    double radius;
    // Constructor for circle
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }
    // Constructor for rectangle
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }
    double area() {
        switch(shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

Такие теговые классы имеют множество недостатков. Они загромождены
стандартными шаблонами, включая объявления enum, поля тегов и операторы switch. Объем памяти увеличивается, поскольку экземпляры перегружены нерелевантными полями, принадлежащими другим разновидностям. Конструкторы должны установить поле тега и инициализировать правильные поля данных без помощи компилятора.

Теговый класс — это просто бледная имитация иерархии классов. Чтобы преобразовать класс с тегами в иерархию классов, сначала определите абстрактный класс
, содержащий абстрактный метод для каждого метода в классе с тегами, поведение которого зависит от значения тега. Затем определите конкретный подкласс корневого класса для каждой разновидности исходного помеченного класса.

Например

abstract class Figure {
    abstract double area();
}
class Circle extends Figure {
    final double radius;
    Circle(double radius) { this.radius = radius; }
    @Override double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
    final double length;
    final double width;
    Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
    @Override double area() { return length * width; }
}

Таким образом, когда вы сталкиваетесь с существующим классом с полем тега, рассмотрите возможность реорганизации его в иерархию.

Правило 24. «Отдайте предпочтение статическим классам-членам, а не нестатическим».

Существует четыре вида вложенных классов: статические классы-члены, нестатические классы-члены, анонимные классы и локальные классы.

Статический класс-член — это обычный класс, который объявлен внутри другого класса и имеет доступ ко всем членам окружающего класса, даже к тем, которые объявлены закрытыми. Он доступен только внутри окружающего класса. Статический класс-член часто используется в качестве общедоступного вспомогательного класса, полезного
только в сочетании со своим внешним классом.

Разница между статическими и нестатическими классами-членами заключается в том, что каждый экземпляр нестатического класса-члена неявно связан с охватывающим экземпляром содержащего его класса, ассоциация занимает место в экземпляре нестатического класса-члена и увеличивает время его построения. Если экземпляр вложенного класса может существовать изолированно от экземпляра окружающего его класса, тогда вложенный класс должен быть статическим классом-членом.

Одним из распространенных применений нестатического класса-члена является определение адаптера, который позволяет рассматривать экземпляр внешнего класса как экземпляр некоторого несвязанного класса.

Если вы объявляете класс-член, которому не требуется доступ к охватывающему
экземпляру, всегда добавляйте модификатор static в его объявление, делая его статическим
, а не нестатическим классом-членом. Если вы опустите этот модификатор, каждый экземпляр будет иметь скрытую постороннюю ссылку на окружающий его экземпляр. Это может привести к тому, что включающий экземпляр будет сохранен, хотя в противном случае он подошел бы для сборки мусора.

Анонимный класс не имеет имени. Он не является членом окружающего его класса. Вместо того, чтобы объявляться вместе с другими членами, он одновременно объявляется и создается в момент использования.

Пункт 25 «Ограничить исходный файл одним классом верхнего уровня»

Никогда не помещайте несколько классов или интерфейсов верхнего уровня в один исходный файл. Следование этому правилу гарантирует, что у вас не может быть нескольких определений для одного класса во время компиляции. Это, в свою очередь, гарантирует, что файлы классов, сгенерированные при компиляции, и поведение результирующей программы не зависят от порядка, в котором исходные файлы передаются компилятору.

Закажите эффективную книгу по Java 3rd edition для автора Джошуа Блохздесь.