Написать пост

Вложенные классы и лямбда-выражения в Java

Вложенный класс Java помогает сделать код более модульным, позволяя сгруппировать связанные классы вместе. Разбираемся, как им пользоваться.

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

Объявляем класс для примера
Статические вложенные классы Java
Простые внутренние классы
Локальные классы
Анонимные классы и лямбда-выражения
«Затенение» переменных
Замыкание переменных
Ссылки на методы
Общее резюме

Согласно документации Java, внутренние (nested) классы бывают двух типов:

  1. статические (static nested classes)
  2. нестатические (inner classes)

Нестатические имеют два подвида: локальные (local classes) и анонимные (anonymous classes). Они схожи и мы рассмотрим их во второй части статьи.

Объявляем класс для примера

Класс в Java позволяет объединять в себе определённые атрибуты и поведение, свойственные объекту. Например, в контексте издательства можно рассмотреть книгу как класс «Book» со следующими полями:

  • Название книги
  • Имя автора
  • Адрес типографии
  • Объём тиража
  • Фамилия редактора
  • Дата издания
  • Текст произведения

А в качестве поведения задать методы, позволяющие:

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

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

			public class Book {


  private Cover cover;
  private List pages;
  private String editor;


  public class Cover {
      //some code here
  }


  public static class Page {
     //some code here
  }
}
		

Примечание Примеры кода в статье представлены в кратком виде и актуальны только для объяснения сути материала. Не стоит относиться к ним как к отображению предметной области в реальном проекте.

Классы обложки и страницы находятся внутри класса книги, но они немного отличаются: «Cover» объявлен как внутренний класс, а класс «Page» — вложенный статический. Это примерная канва процесса создания книги, чтобы объяснить подход к формированию архитектуры приложения, поэтому детали остаются за скобками. Мы не берёмся разработать конкурента InDesign или написать свой WoodWing.

Почему мы объявляем классы именно так? Допустим, работа над книгой начинается, когда автор приносит текст. Сначала нужно определить, в каком он состоянии, требуется ли ему доработка, какого объёма текст. Значит, для работы понадобятся составляющие будущей книги — страницы, каждая из которых будет содержать свой отрывок произведения. Объекты страниц передаются на проверку орфографии, сверку данных, затем попадают на правки к главному редактору. У страниц может измениться размер, могут быть дописаны или сокращены целые главы. Всё это повлияет на конечный вид будущей книги. Таким образом, вполне закономерно, что объекты класса «Page» могут существовать до того, как будет сформирован объект книги.

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

Статические вложенные классы Java

Статический класс «Page» содержит текст страницы книги и её геометрические размеры:

			public class Book {


  private Cover cover;
  private List pages;
  private String editor;


  public class Cover {
      //some code here
  }


  public static class Page {


    private static final int DEFAULT_WIDTH = 210;
    private static final int DEFAULT_HEIGHT = 297;


    private final int width;
    private final int height;
    private final String text;


    public Page(String text) {
      this(text, DEFAULT_WIDTH, DEFAULT_HEIGHT);
    }


    public Page(String text, int width, int height) {
      this.text = text;
      this.width = width;
      this.height = height;
    }
  }
}
		

Раз нам нужно работать с текстом сразу, то воспользуемся свойством статических классов — экземпляр такого класса может существовать самостоятельно, для его создания не нужен объект внешнего класса. И для создания объекта «Page» нужно обратиться к конструктору через имя внешнего класса и точку:

			Book.Page page = new Book.Page();
		

Именно эта особенность позволяет создать объекты-страницы до того, как они будут объединены в один общий объект-книгу. При этом она диктует и ограничение: из вложенного класса нельзя напрямую обращаться к полям и методам внешнего класса. Ведь объект page существует отдельно и не знает, создан ли какой-либо объект класса «Book», где находится на него ссылка. Но из класса «Page» доступно обращение к статическим полям и методам внешнего класса, в том числе и отмеченным модификатором private.

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

Также статический класс не имеет ограничений по реализации интерфейсов или наследованию. Он может наследовать или сам выступать в роли родительского класса.

Простые внутренние классы

Класс «Cover» — внутренний или нестатический вложенный класс в Java. Его объекты создаются только через объект внешнего класса и не могут существовать без последнего. Выделить в такой класс данные обложки вполне логично, так как она принадлежит только конкретным книге и изданию:

			public class Book {


  private Cover cover;
  private List pages;
  private String editor;


  public class Cover {


     private String title;
     private String author;
     private LocalDate publicationDate;
     private Integer copyCount;


     public String getEditor(){
        return editor;
     }


     public Integer getBookSize(){
        return pages.size();
     }
  }


  public static class Page {
     //some code here
  }
}
		

Кроме своих полей и методов, объект внутреннего класса имеет полный доступ к данным внешнего класса, даже обозначенным модификатором private. Таким образом, через объект cover можно получить другие параметры книги, например, фамилию редактора или количество страниц:

			Book book = new Book();
Book.Cover cover = book.new Cover();


String editor = cover.getEditor();
Integer bookSize = cover.getBookSize();
		

Доступность переменных внешнего класса объясняется просто — объект создаётся на основе существующего объекта внешнего класса. Это гарантирует, что его переменные уже созданы и инициализированы, а значит к ним можно обратиться за данными. Более того, объект внутреннего класса хранит ссылку на объект внешнего, благодаря которому он был создан. Чтобы получить её, достаточно добавить в код следующий метод:

			public class Cover {

    public Book getBook() {
        return Book.this;
    }
}


Book book = new Book();
Book.Cover cover = book.new Cover();


Book outerBook = cover.getBook();
assert book == outerBook;
		

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

Также внутренний класс, как и вложенный, может быть унаследован, выступать в роли родительского класса или реализовывать интерфейс — ограничений нет.

Резюме

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

Локальные классы

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

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

Особенности локальных классов:

  • объявленный класс принадлежит блоку кода, в котором объявлен, и видимость ограничена рамками этого блока;
  • единственная доступная область видимости — default;
  • не может быть статическим или содержать статических методов или статических блоков кода, за исключением констант;
  • из локального класса разрешён доступ к полям и методам обрамляющего класса;
  • ему доступны локальные переменные блока, если они имеют модификатор final или являются effectively final;
  • может наследовать, быть унаследован таким же локальным классом, или реализовать интерфейс.

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

			public class Application {


  public static void main(String[] args) {
     Book book = new Book();


     class FilterByCopyCount {
        public Boolean checkCopies(Book book) {
           return book.getCover().getCopyCount() > 1000;
        }
     }


     FilterByCopyCount filter = new FilterByCopyCount();
     Boolean bestseller = filter.checkCopies(book);
  }
}
		

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

Анонимные классы и лямбда-выражения

Анонимные классы схожи с локальными по характеристикам, за исключением того, что у них нет имени. Чтобы разобрать его синтаксис, возьмём за основу интерфейс Function, содержащий метод:

			R apply(T t);


где:
T - тип входящих данных,
R - тип исходящих данных;
		

Теперь применим этот интерфейс к нашей задаче, реализовав в анонимном классе функцию, проверяющую размер тиража:

			Function<Book, Boolean> myFunction = new Function<>() {
  @Override
  public Boolean apply(Book book) {
     return book.getCover().getCopyCount() > 1000;
  }
};
		

Анонимный класс находится в фигурных скобках. Ссылка на объект этого класса присвоена переменной myFunction. Его можно передать как параметр в любой метод, так как область видимости интерфейса Function ничем не ограничена.

Но и в этом решении всё ещё много boilerplate-кода. Как сделать эту запись эффективней? Здесь на помощь приходят лямбда-выражения.

Лямбда-выражение — это анонимная функция для реализации метода функционального интерфейса:

			Function<Book, Boolean> myFunction =
  book -> book.getCover().getCopyCount() > 1000;


Boolean bestseller = myFunction.apply(myBook);
		

Она делится на три части:

  • «->» – символ лямбда-выражения;
  • часть слева – входные параметры типа ‘T’ (в данном случае – Book);
  • часть справа – функция, возвращающая тип ‘R’ (в данном случае – Boolean).

Лямбда-выражение можно рассматривать как инструкцию, доступную в любом месте кода. Она позволяет реализовать функцию и передать её как параметр в другой метод. Также с ней можно не создавать отдельный класс, а просто описать необходимые действия. Lambda в чём-то похожа на анонимный класс, за исключением, что анонимный класс может иметь своё состояние, а лямбда-выражение — нет.

Чтобы окончательно убедиться, что лямбда-выражение — это не синтаксический сахар над анонимным классом, обратимся к текущему объекту и получим его хеш-код. Анонимный класс при запросе this.hashCode() вернёт хеш-код экземпляра анонимного класса, а лямбда-выражение — хеш-код объекта класса, внутри которого она вызвана:

			public class Typography {


  public void doWork() {
     Supplier anonymousClass = new Supplier<>() {
        @Override
        public Integer get() {
           return this.hashCode();
        }
     };


     Supplier anonymousFunction = () -> this.hashCode();


     System.out.println("anonymousClass: " + anonymousClass.get());
     System.out.println("anonymousFunction: " + anonymousFunction.get());
     System.out.println("this: " + this.hashCode());
  }
}
		

И получим вывод в консоли:

			anonymousClass: 1694819250 
anonymousFunction: 708049632
this: 708049632
		

«Затенение» переменных

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

			public class Typography {


  private int x = 10;


  public void doWork() {
     final int x = 31;


     Consumer anonymousClass = new Consumer<>() {
        final int x = 314;


        @Override
        public void accept(Integer x) {
           System.out.println("x = " + x);
           System.out.println("this.x = " + this.x);
           System.out.println("Typography.this.x = " + Typography.this.x);
        }
     };


     anonymousClass.accept(x);
  }
}
		

Вывод в консоли:

			x = 31
this.x = 314
Typography.this.x = 10
		

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

			public class Typography {


  private int x = 10;


  public void doWork() {
     int x = 31;


     Consumer anonymousFunction = y -> {
        //int x = 314; - ошибка компиляции
  //int y = 0; - ошибка компиляции
        System.out.println("y = " + y);
        System.out.println("x = " + x);
        System.out.println("this.x = " + this.x);
     };


     anonymousFunction.accept(x);
  }
}
		

Вывод в консоли:

			y = 31
x = 31
this.x = 10
		

Замыкание переменных

Замыкание переменных — это функция, которая захватывает («замыкает») внешние переменные и использует их в своём теле.

Реализация лямбда-выражением интерфейса — это объект анонимного класса. А объекты в JVM хранятся в heap’е, тогда как локальные переменные метода — в stack. Соответственно, если объект будет создан при помощи лямбда-выражения и размещён в heap, будет ли доступна переменная в стеке? А если выполнение локального метода завершено, стек был очищен, и только потом был произведён вызов сохранённого выражения? Избежать возможных коллизий можно сделав копию использованной переменной.

Таким образом, лямбда-выражение уносит в heap копию значения локальной переменной. И если лямбда-выражение изменяет значение переменной или переменная меняется в процессе исполнения локального метода, то добиться согласованности можно двумя вариантами:

  • запретить изменять переменные, обозначив их как final,
  • воспользоваться пакетом ​​java.util.concurrent.

Причин у этого две:

  1. изменение значений у переменных в Java не потокобезопасное;
  2. лямбда-выражение может быть использовано в любом месте кода, тогда как область видимости локальной переменной ограничена блоком кода.

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

Такая запись не вызывает ошибок:

			public void doWork() {
  AtomicInteger counter = new AtomicInteger(5);
  String value = "Value is: ";


  BiConsumer<String, AtomicInteger> consumer = (value1, counter1) -> {
     while (counter1.getAndDecrement() > 0) {
        System.out.println(value1 + counter1.get());
     }
  };


  consumer.accept(value, counter);
 
  counter.set(57);
}
		

Вывод в консоли:

			Value is: 4
Value is: 3
Value is: 2
Value is: 1
Value is: 0
		

Ссылки на методы

Ещё один способ сделать код более читаемым — передавать в виде аргумента ссылку на метод. Реализация в коде будет выглядеть как имя класса или объекта, два двоеточия и имя метода:

			ClassName::methodName
		

или

			myObject::methodName
		

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

			Function<Book.Cover, Integer> function = cover -> cover.getBookSize();
		

А  с использованием ссылки на метод:

			Function<Book.Cover, Integer> function = Cover::getBookSize;
		

Общее резюме

  1. Выделение класса, как внутреннюю часть другого класса, напрямую зависит от контекста приложения, предметной области и решаемой задачи.
  2. Если вы решили использовать внутренний нестатический класс — ещё раз пересмотрите задачу и все вводные. Внутренний нестатический класс скорее антипаттерн, нежели инструкция к применению. Кроме приведённого примера Джошуа Блохом в «Effective Java» реализации итератора, а также EntrySet и KeySet, сложно вспомнить применение такому типу. Пишите в комментариях, если я что-то упустил.
  3. Локальный класс — тоже проходной тип, лучше использовать его как можно реже.
  4. Анонимный класс используется чуть чаще, уже в более специфических задачах, например, как Stream API.
  5. Лямбда-выражение — безусловный лидер по удобству и частоте употребления.
Следите за новыми постами
Следите за новыми постами по любимым темам
4К открытий5К показов