Reflection Means Hidden Coupling

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

Рефлексивное программирование (или рефлексия) происходит, когда ваш код меняет себя на лету. Например, метод класса, при вызове которого, среди прочего, добавляет новый метод к классу (также известный как модификация кода на лету). Java, Python, PHP, JavaScript, назовите их — все они имеют эту “мощную” функцию. Что с этим не так? Ну, это медленно, опасно и сложно читать и отлаживать. Но все это ничто по сравнению с связыванием, которое оно вносит в код.

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

Here is the code:

public int sizeOf(Iterable items) {
  return ((Collection) items).size();
}

Я не уверен, что все согласятся с тем, что это является отражением, но я верю, что это так: мы проверяем структуру класса во время выполнения, а затем вызываем метод size(), который не существует в Iterable. Этот метод появляется только во время выполнения, когда мы создаем динамическую ярлык для него в байт-коде.

Почему это плохо, помимо того, что 1) это медленно, 2) это более громоздко и поэтому менее читаемо, и 3) это вводит новую точку отказа, так как объект items может не быть экземпляром класса Collection, что приведет к MethodNotFoundException?

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

Этот метод может сработать, а может и нет. Все зависит от фактического класса list. Если это Collection, вызов sizeOf будет успешным. В противном случае возникнет ошибка времени выполнения. Исходя из метода calc, нельзя сказать, каким образом правильно обработать list, чтобы избежать ошибки времени выполнения. Нам необходимо прочитать содержимое метода sizeOf, и только после этого мы можем изменить calc на нечто подобное:

Этот код пока кажется нормальным. Однако, что произойдет, если sizeOf изменит свою реализацию на что-то подобное (я взял это из этой статьи о приведении типов):

Теперь sizeOf отлично справляется с любым типом, который поступает на вход, будь то экземпляр Collection или нет. Однако метод calc не знает о внесенных изменениях в метод sizeOf. Вместо этого он все еще считает, что sizeOf сломается, если получит что-то, кроме Collection. Чтобы их синхронизировать, нам нужно помнить, что calc слишком много знает о sizeOf и придется изменять его при изменении sizeOf. Таким образом, можно сказать, что calc связан с sizeOf, и эта связь скрыта: скорее всего, мы забудем изменить calc, когда sizeOf получит лучшую реализацию. Более того, в программе может быть множество других мест, подобных calc, которые мы должны помнить изменить при изменении метода sizeOf. Очевидно, что мы забудем о большинстве из них.

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

Consider this code:

class Book {
  private String author;
  private String title;
  Book(String a, String t) {
    this.author = a;
    this.title = t;
  }
  public void print() {
    System.out.println(
      "The book is: " + this.name()
    );
  }
  private String name() {
    return this.title + " by " + this.author;
  }
}

Как бы вы написали модульный тест для этого класса и его метода print()? Очевидно, это практически невозможно без рефакторинга класса. Метод print отправляет текст в консоль, который не так просто замокать, так как он “статический”. Правильным путем было бы сделать System.out инъектируемым как зависимость, но некоторые из нас считают, что рефлексия является более предпочтительным вариантом, что позволило бы нам тестировать приватный метод name напрямую, без вызова print сначала:

Вы также можете использовать библиотеку Java PowerMock, чтобы делать множество “красивых” вещей с приватными методами.

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

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

Я беру класс Book и хочу изменить его, просто заставить метод name возвращать StringBuilder вместо String. Это довольно невинное изменение, которое может потребоваться по соображениям производительности. Прежде чем я начну вносить какие-либо изменения, я запускаю все тесты (это хорошая практика) и все они проходят успешно. Затем я вношу свои изменения, ожидая, что ни один из тестов не провалится:

Однако тест BookTest завершится неудачно, потому что он ожидает, что мой класс Book будет иметь метод name, который возвращает String. Если это не мой тест или я написал его давно, мне было бы неприятно узнать об этом факте: тест ожидает, что я напишу мои приватные методы только одним конкретным способом. Почему? В чем проблема с возвратом StringBuilder? Я подумал бы, что есть какая-то скрытая причина для этого. Иначе зачем тест требует чего-либо от приватной реализации класса? Очень скоро, после некоторого исследования, я бы узнал, что причины отсутствуют. Это просто предположение, которое сделал тест о внутренностях Book, и у этого предположения нет никаких оснований, кроме “Мы не успели перестроить класс и сделать System.out внедряемым”.

Кстати, данный подход к тестированию известен как анти-паттерн “Инспектор”.

Что бы я делал дальше? Мне бы пришлось откатить свои изменения, а затем начать рефакторить тест и класс, чтобы избавиться от этого предположения. Однако изменение теста и одновременное изменение основного кода, я считаю, опасная практика: скорее всего я внесу новые ошибки.

Тесты больше не являются для меня “страховым поясом”. Я не могу им доверять. Я изменяю код и знаю, что ничего не сломал. Однако тест даёт мне красный сигнал. Как я могу ему доверять, если он лжет в таком простом сценарии?

Такая связь между модульным тестом BookTest и классом Book не возникла бы, если бы не было возможности использовать рефлексию в первую очередь. Если никто не имел бы возможности достичь приватного метода никаким образом, то анти-паттерн “Инспектор” в модульных тестах не был бы возможен.

Конечно, жизнь была бы еще лучше, если бы у нас не было приватных методов.

Вот как может работать типичный завод:

Фабричный метод называется make. Он ожидает, что будет предоставлено имя “оператора”, а затем, используя Class.forName() из Java Reflection API, строит имя класса, находит его в класспасе и создает экземпляр этого класса. Допустим, есть два класса, оба реализующие интерфейс Operator:

Затем мы используем их, сначала запрашивая наш метод фабрики создать объекты по именам операторов.

result будет равен 13.

Мы не смогли бы сделать это без отражения. Мы бы должны были сделать это вместо этого:

Если спросите меня, этот код выглядит гораздо более читаемым и поддерживаемым. Прежде всего, потому что в любой среде разработки, позволяющей навигацию по коду, можно было бы щелкнуть по OpMinus или OpPlus и сразу же перейти к телу класса. Во-вторых, логика поиска класса предоставляется JVM “из коробки”: мне не нужно догадываться, что происходит при вызове make("Plus").

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

В Java вы можете прикрепить аннотацию (экземпляр интерфейса DTO-подобного) к классу (или его элементу, такому как метод или аргумент). Информацию из аннотации можно читать во время выполнения или во время компиляции. В современных фреймворках, таких как Spring, эта функция часто используется для автоматизации проводки объектов: вы просто прикрепляете некоторые аннотации к вашим классам, и фреймворк найдет их, создаст экземпляры, поместит их в контейнер DI и присвоит другим атрибутам объектов.

Я уже говорил, что этот механизм обнаружения объектов и автоматической их связи является анти-паттерном. Я также говорил ранее, что аннотации - это анти-паттерн. Ни контейнеры внедрения зависимостей, ни автоматическая проводка, ни аннотации не существовали бы без рефлексии. Жизнь была бы намного лучше, а Java/OOP была бы более чистой.

Клиенты аннотированных объектов/классов связаны с ними, и эта связь скрыта. Аннотированный объект может изменить свой интерфейс или изменить аннотации, и код будет успешно компилироваться. Проблема возникнет только позже во время выполнения, когда ожидания других объектов не будут удовлетворены.

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

Чтобы позволить сериализационному движку преодолевать преграды инкапсуляции, язык программирования должен иметь рефлексию. Во-первых, потому что некоторые поля DTO могут быть приватными и поэтому доступны только через рефлексию. Во-вторых, даже если DTO разработан “правильно” со всеми необходимыми геттерами для приватных полей, все равно требуется рефлексия, чтобы понять, какие геттеры присутствуют и могут быть вызваны.

Отношение сериализации к объектам очень похоже на то, что делает ORM. Ни одно из них не общается с объектами, а, наоборот, они “оскорбительно” их разрушают, отбирая то, что им нужно, и оставляя объекты в бессознательном состоянии. Если в будущем объект решит изменить свою структуру, переименовать некоторые поля или изменить типы возвращаемых значений, другие объекты, фактически связанные с объектом через сериализацию, ничего не заметят. Они заметят, но только во время выполнения, когда появятся исключения “неверный формат данных”. Разработчики объекта не будут иметь возможности заметить, что их изменения в интерфейсе объекта влияют на другие места в кодовой базе.

Можно сказать, что сериализация - это “идеальный” метод связывания двух объектов так, чтобы ни один из них не знал об этом.

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

Очевидно, что сериализация в таком “насильственном” виде была бы невозможна, если бы не было рефлексии в первую очередь. Более осторожная сериализация была бы возможна и использовалась бы, не через рефлексию, а с помощью принтеров, реализованных объектами.

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

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-12-16 at 15:56

sixnines availability badge   GitHub stars