Давайте использовать O.O.P. и Ф.П. шаблоны для устранения запаха кода Null-Island.

Недавно я наткнулся на профиль Максимилиано Контьери и все его проницательные посты о различных запахах кода и анти-паттернах. Читая его статьи, я нашел одну из них под названием «Null Island», и я хотел бы представить вам этот антишаблон и два альтернативных решения, которые, на мой взгляд, помогут вам обогатить вашу модель предметной области.



Code Smell 208 — Null Island
Вы можете избежать null, если попробуетеlevelup.gitconnected.com



Запах кода нулевого острова

Запах кода "нулевого острова" можно кратко описать как код, который разветвляется на поле, допускающее значение NULL, или опирается на определенные ненулевые значения, которые по соглашению считаются "особыми". В исходном примере, написанном на Kotlin, это местоположение с координатами (0,0) является одним из таких специальных значений и предназначено для представления человека из неизвестного местоположения:

val people = listOf(
    Person("Alice", 40.7128, -74.0060), // New York City
    Person("Bob", 51.5074, -0.1278), // London
    Person("Charlie", 48.8566, 2.3522), // Paris
    Person("Tony Hoare", 0.0, 0.0) // Null Island
)

for (person in people) {
    if (person.latitude == 0.0 && person.longitude == 0.0) {
        println("${person.name} lives on Null Island!")
    } else {
        println("${person.name} lives at (${person.latitude}, ${person.longitude}).")
    }
}

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

val rio = Coordinates(-22.9068, -43.1729) // Rio de Janeiro coordinates
for (person in people) {
    try {
        val distance = person.location.calculateDistance(rio)
        println("${person.name} is ${distance} kilometers from Rio de Janeiro.")
    } catch (e: IllegalArgumentException) {
        println("${person.name} is at an unknown location.")
    }
}

Хотя это сработает, я стараюсь не управлять потоком приложения на основе исключений. Кроме того, предложение catch «проглатывает» исключение, не оставляя следов проблемы.

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

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

«Полиморфный» человек

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

Первое решение: поскольку человек может указать или не указать местоположение, мы должны смоделировать два разных представления Person и избегать наличия нулевой ссылки:

interface Person {
   String name();
}

record GeolocatedPerson ( String name, Location location) implements Person {}

record UnlocatedPerson ( String name ) implements Person {}

Теперь давайте «доверим» Person, чтобы он сообщил нам расстояние между ним и другим местом на карте. Мы полиморфно реализуем это в двух представлениях:

interface Person {
   String name();
   String distanceTo(Location location);
}

record GeolocatedPerson( String name, Location location ) implements Person {
   @Override
   public String distanceTo(Location otherLocation) {
      return "%s is %.2f kilometers from %s".formatted(
          name, location.distanceTo(otherLocation), otherLocation.name()
      );
   }
}

record UnlocatedPerson( String name ) implements Person {
   @Override
   public String distanceTo(Location otherLocation) {
      return "%s lives is at an unknown location, cannot calculate distance to %s".formatted(
          name, otherLocation.name()
      );
   }
}

Теперь давайте посмотрим, как мы можем использовать эти различные реализации интерфейса Person:

@Test
void test() {
    var people = List.of(
        new GeolocatedPerson("Alice", new Location("New York", 40.7128, -74.0060)),
        new GeolocatedPerson("Bob", new Location("London", 51.5074, -0.1278)),
        new GeolocatedPerson("Charlie", new Location("Paris", 48.8566, 2.3522)),
        new UnlocatedPerson("Tony")
    );

    var rio = new Location("Rio de Janeiro", -22.9068, -43.1729);
    people.stream()
        .map(pers -> pers.distanceTo(rio))
        .forEach(dist -> System.out.println(dist));
}

Расположение «Monadic»

В качестве альтернативы мы можем инкапсулировать эту логику внутри объекта Location. Для этого мы будем использовать (почти) монадический подход. Другими словами, Person всегда будет иметь Location, но само местоположение может быть известно или неизвестно.

Для выражения этих двух возможных состояний Location без сохранения ссылки null или Optional мы снова воспользуемся полиморфизмом:

public interface Location {
    String name();
} 

record KnownLocation( double longitude, double latitude ) implements Location {
    @Override
    public String name() {
        return "(%s, %s)".formatted(longitude, latitude);
    }
}

record UnknownLocation() implements Location {
    @Override
    public String name() {
       return "Null Island";
    }
}

Для удобства мы можем добавить в публичный интерфейс несколько статических фабричных методов:

public interface Location {
    String name();

    static Location of(double longitude, double latitude) {
        return new KnownLocation(longitude, latitude);
    }

    static Location unknown() {
        return new UnknownLocation();
    }
}

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

record KnownLocation(double longitude, double latitude) implements Location {
    @Override
    public String name() {
        return "(%s, %s)".formatted(longitude, latitude);
    }

    public double distanceTo(KnownLocation other) { 
        retrun ...; // the calculation goes here
    }
}

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

interface Location {
    String name();

    static Location of(double longitude, double latitude) {
        return new KnownLocation(longitude, latitude);
    }

    static Location unknown() {
        return new UnknownLocation();
    } 

    void ifKnownOrElse(Consumer<KnownLocation> action, Runnable orElse);
}


record KnownLocation( double longitude, double latitude ) implements Location {
    @Override
    public String name() {
        return "(%s, %s)".formatted(longitude, latitude);
    } 
    @Override
    public void ifKnownOrElse(Consumer<KnownLocation> action, Runnable orElse) {
        action.accpet(this);
    }
}

record UnknownLocation() implements Location {
    @Override
    public String name() {
       return "Null Island";
    } 
    @Override
    public void ifKnownOrElse(Consumer<KnownLocation> action, Runnable orElse) {
        orElse.run();
    }
}

Наконец, давайте соберем все вместе и посмотрим, как использовать этот монадический объект Location:

@Test
void test() {
    var people = List.of(
        new Person("Alice", Location.of( 40.7128, -74.0060)),
        new Person("Bob", Location.of( 51.5074, -0.1278)),
        new Person("Charlie", Location.of(48.8566, 2.3522)),
        new Person("Tony", Location.unknown())
    );

    var rio = new KnownLocation(-22.9068, -43.1729);
    people.forEach(pers -> pers.location().ifKnownOrElse(
            loc -> System.out.println("%s is %.2f kilometers from Rio".formatted(pers.name(), loc.distanceTo(rio))),
            () -> System.out.println("%s is at an unknown location".formatted(pers.name()))
        )
    );
}

Слово о монадах

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

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

Например, это будет пример «функтора» (похожего на Optional.map, Flux.map или CompletableFuture.thenApply):

interface Location {
    // ...
    Location moveToTheNorth(int kms);
}

record KnownLocation( double longitude, double latitude ) implements Location {
    // ... 
    @Override
    public void moveToTheNorthint(int kms) {
        return new KnownLocation(loc.latitude + kms, loc.longitude);
    }
}

record UnknownLocation() implements Location {
    // ... 
    @Override
    public void moveToTheNorthint(int kms) {
        return this;
        // if it is an UnkownLocation, it will remain unkown
    }
}

Кроме того, монада «true» также будет иметь эквивалент метода flatMap, но это будет довольно сложно продемонстрировать в нашем контексте. Если вы хотите узнать больше о Mondas в целом и Необязательно в частности, я предлагаю вам прочитать одну из моих предыдущих статей и поделиться своим мнением:



Заключение

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

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

Я рекомендую вам ознакомиться с другими статьями, написанными Максимилиано Контьери на его странице: https://mcsee.medium.com/about

Спасибо!

Спасибо за прочтение статьи и, пожалуйста, дайте мне знать, что вы думаете! Любая обратная связь приветствуется.

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

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

Удачного кодирования!

Повышение уровня кодирования

Спасибо, что являетесь частью нашего сообщества! Перед тем, как ты уйдешь:

  • 👏 Хлопайте за историю и подписывайтесь на автора 👉
  • 📰 Смотрите больше контента в публикации Level Up Coding
  • 💰 Бесплатный курс собеседования по программированию ⇒ Просмотреть курс
  • 🔔 Подписывайтесь на нас: Twitter | ЛинкедИн | "Новостная рассылка"

🚀👉 Присоединяйтесь к коллективу талантов Level Up и найдите прекрасную работу