Современный подход ООП к условному оператору if/else

Все знают IF/ELSE IF/ELSE IF/…./ELSE . А также всем известно зло внутри множества if/else_if/…. или нет?
на самом деле нет?
Итак, пусть эти образы покажут зло:

Теперь вы убеждены? Что ж.
Итак, давайте начнем открывать новые способы обработки логики if/else_if/…/else.
(Это руководство основано на Java, но идеи можно применить и к C#, C++, Javascript, Python …)

Предположение 1:

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

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

Итак, о чем мы говорим? Конечно, не «избегать сравнения», а «улучшать способы, которые мы используем для сравнения».

Предположение 2:

сценарии, описанные в этом руководстве, имеют общее свойство: все if/else_if/../else действуют на (почти) один и тот же набор выходных данных или предоставляют 1 выходной сигнал одного типа, например:

/* SAMPLE 1 */
// here we return always an integer
if (logic1) { 
  return 1; 
} else if (logic2) { 
  return 2; 
} else if (logic3) { 
  return 3; 
}

OR

/* SAMPLE 2 */
// here we do something on 'else'
if (logic1) {
  x = ..;
  y = ..;
}
else if (logic2) {
  x = ..;
  y = ..;
} else if (logic3) {
  x = ..;
  y = ..;
} else if (logic 4) {
  x = ..;
  // not assign a value for y, here...
}

ЧАСТЬ 1: сравнение ENUMERABLES

Что такое перечисления? Это значения, для которых у нас может быть группа/коллекция, содержащая эти значения; Перечисления — это конечное число элементов, хорошо известных априори, то есть: когда мы пишем код сравнения, мы уже знаем значения, с которыми мы сравниваем нашу переменную.

Таким образом, мы могли бы собрать эти значения, используя некоторые определенные типы коллекций (Java):

  • Набор, который допускает не нулевые значения и не несколько одинаковых значений: то есть нет кратных «А», «А», «А» или «нуль».
  • Карта, которая связывает значение с ключом, а набор ключей — это просто набор; Сравнение Enumerables можно использовать, когда мы можем применить простую логику к строкам/целым числам/любому_native (простой сценарий) или к некоторому набору объектов, для которых у нас может быть своего рода пользовательское сравнение.

Вариант использования A: простое сравнение строк/объектов

Мы могли бы написать что-то вроде:

/* SAMPLE 3: */

String s = ...;
if (s.equals("A") 
    || s.equals("B")) 
    || s.equals("C")) 
    || ... 
    || s.equals("Z")) { 
  ... 
}

Итак, действительно ли мы должны написать код, как показано ниже? Нет, мы бы не хотели, но часто я видел код, как пример выше, действительно: когда-то было 37 if/if/if/…, чтобы со временем расти с новыми случаями.

Конечно, мы могли бы использовать if /else if/else if/else.. но дело в том, что мы делаем N сравнений, пока не найдем точное совпадение (если оно есть), поэтому наш Код if/if/if стоит O(n) и очень неэффективен, его трудно читать, тестировать, поддерживать, обогащать… потому что для каждого нового случая нам приходится модифицировать код.

Решение A: используйте набор

Решение A1: для строк

/* SAMPLE 4 */

Set<String> mySet = Set.of("A", "B", "C", "D", ..., "Z");
// or, better: the set could be populated/injected from 
// db/configuration/any_external_source_easy_to_maintain
// example, using Spring:
// @Value(${mySet})
// private Set<List> mySet;
// and then:
if (mySet.contains(myString)) { 
  ... 
}

и вуаля: стоимость O(1), потому что поиск в операции Set стоит O(1), код действительно эффективен, удобочитаем и удобен в сопровождении, и если вы хотите добавить еще один случай, достаточно добавить другая строка для установки (или исходный источник: внешняя конфигурация, таблица базы данных и т. д.)

Решение A2: для стандартного объекта Java
Если наша переменная является Integer/Double/Date/…, мы все равно можем использовать Set для сбора перечисляемых типов, например:

/* SAMPLE 5 */

Set<Date> dates = ...;
// and
if (dates.contains(ourDates)) {
   ...;
}

Решение A3: простое сравнение пользовательского объекта

/* SAMPLE 6 */

// let we have a custom object
class MyObject {
  String id;
  String name;
}

// we could still use Set to collect our enumerable objects:
Set<MyObject> myObjectSet = Sets.of(..., ..., ...);

// and, so:
if (myObjectSet.contains(myObject)) {
 ...;
}

// BUT, careful: our MyObject must implement Comparable, 
// or Set will not work (AT RUNTIME!);
// so, let our object implements Comparable:

class MyObject implements Comparable {
  String id;
  String name;
  @Override
  public int compareTo(final MyObject o) {
    return o.id.compareTo(id);
  }
}

// now MyObject implements Comparable#compareTo method, 
// and automatic comparison from Set will work when we use 'contains' method

Вариант использования B: сравнение строк и выполнение чего-либо

/* SAMPLE 7 */

String s = ...;
if (s.equals("A")) { doingForA }
if (s.equals("B")) { doingForB }
if (s.equals("C")) { doingForC }
...
if (s.equals("Z")) { doingForZ }

Подобно случаям A, но здесь мы будем выполнять определенные действия в соответствии с каждым IF.

Для этого сценария мы могли бы воспользоваться преимуществами Map+Lambda (если мы работаем на Java; в Javascript может использоваться литеральный объект; в C# есть словарь+делегат и т. д.).

Во-первых, давайте определим интерфейс Action с учетом дженериков:

/* SAMPLE 8.a */

interface Action<I,R> {
  R execute(I input);
}

У нас есть метод «выполнить», принимающий на вход общий I и выдающий результат R; мы могли бы указать некоторый реальный тип, такой как ‹String,String› или также ‹Void,Void›, чтобы иметь нулевой ввод и нулевой вывод или другую комбинацию.

Затем мы используем Map, чтобы связать строку (n-ю строку, которую мы будем сравнивать с нашей строкой) с действием, которое будет выполняться, если мы совпадем с этой строкой.

Этот шаблон называется Map of Function(s) или Functor из старых терминов C++; кроме того, это способ реализации шаблона Стратегия, а иногда и шаблона Команда. Было бы неплохо подробно рассмотреть эти шаблоны.

/* SAMPLE 8.b */

Map<String,Action<String,String>> map = new HashMap<>();

// then populate
map.put("A", new Action<String,String>() {
  @Override
  public String execute(String input) {
    return input.toUpperCase() + "_A";
  }
});
map.put("B", new Action<String,String>() {
  @Override
  public String execute(String input) {
    return input.toUpperCase()+ "_B";
  }
});
map.put("C", new Action<String,String>() {
  @Override
  public String execute(String input) {
    return input.toUpperCase() + "_C";
  }
});
// other strings to handle...

слишком много стандартного кода? поэтому мы могли бы воспользоваться преимуществами компактной лямбда-формы и записать население как:

/* SAMPLE 8.c */

// the same as 8.b, but using Lambda to populate:
map.put("A", input -> input.toUpperCase() + "_A");
map.put("B", input -> input.toUpperCase() + "_B");
map.put("C", input -> input.toUpperCase() + "_C");
...
// nice, really? yes, very nice.

а затем использование:

/* SAMPLE 8.d */

String theInput = ...

Action a = map.get(theInput);
String result;
// because we could not have any match, 
// we have to check a 'null' value Action from Map,
// eventually causing NullPointerException
if (a != null) {
  result = a.execute(myInputToUse);
}

но нам не нравится проверка Null, так что…

/* SAMPLE 8.e */

// we could (should) use Optional from Java8:
String result = Optional
    .ofNullable(map.get(myStringToCheck).execute(myInputToUse))
    .orElse(-1);
// where '-1' is a our default value to use as "else" case;

Другой подход может заключаться в использовании (расширенного) Enum из Java5:

/* SAMPLE 8.f */

public class MyClass;

private final MyService myService = new MyService(); 
// or use @Inject/@Autowired/whatever

// declaring
public enum ActionEnum {
  A {
      @Override
      public String execute(String input) {
        // but this will not work - see below
        myService.doSomething(); 
        return input.toUpperCase() + "_A";
      }
  };
  // other for B, C, ...

  public abstract String execute(String input);
}

// usage:
public void myMethod() {
  ActionEnum.A.execute("asd");
}

Здесь код функции скрыт в методе «выполнить», который реализует каждый экземпляр Enum: это может быть коротким подходом для определенных сценариев или нет .. это зависит; У Enum есть свои плюсы и минусы: он компилируется во время и статически разрешается, поэтому:

  • плюсы: ActionEnum.XXX ‹- не будет компилироваться, если вы попытаетесь выполнить поиск в никогда не объявленном экземпляре перечисления; вместо этого, используя Map, вы не заметите плохие значения, пока не будете использовать их во время выполнения…
  • минусы: если вы объявляете перечисление в другом классе (вложенное перечисление), вы НЕ можете передавать поля экземпляра из окружающего класса в реализации метода выполнения, такие как Closure, потому что Enum статически разрешается JVM; строка «myService.doSomething» не будет работать

Соображения:

Слишком много кода для заполнения карты в примере 8.b? возможно, да, но точно не в образце 8.c.

Слишком сложно читать? опять же, возможно, если вы не знаете Java8 Lambda (поэтому вам пора ее изучить...).

Слишком дорого для исполнения? определенно нет, потому что поиск Map и Enum стоит O(1), поэтому наш код не будет выполнять все if/else_if поиск совпадающего регистра, но он укажет непосредственно на совпадающую строку/перечисление, используя ключ карты (или строка перечисления) хеш, и, наконец, он выполняет внутренний код из метода Action/Enum (который, в шаблоне if/else_if, является кодом в каждом блоке «тогда»).

Конечно, мы могли бы (должны ли?) использовать интеллектуальную IDE (Eclipse, IntellijIdea, что угодно) для завершения/помощи/и т. д. во время написания кода; в примере: Eclipse преобразует шаблонный код из анонимных классов (8.b) в версию Lambda (8.c).

Заключительный этап:

Наконец, мы могли бы не создавать наш пользовательский интерфейс Action и, для этого сценария, использовать напрямую java.util.function, который, по сути, действует как наше Action:

/* SAMPLE 9 */

Map<String,Function<String,Integer>> map = new HashMap<>();

// then populate
map.put("A", new Function<String,Integer>() {
  @Override
  public Integer apply(String input) {
    return 1;
  }
});
....

// java Function provides "apply" method, instead of our custom "execute", but the lambda version is the same as 8b:

// the population
map.put("A", input -> 1);

// and also the same usage from 9a retrieving/executing the Action 
// (here Function, with 'apply'), using explicit check on null or 
// the Optional (better)
Integer result = Optional
  .ofNullable(map.get(myString).apply(theInput))
  .orElse(-1);

java.util.function предоставляет другой полезный интерфейс для реализации функциональной парадигмы:

  • BiFunction (2 входа, 1 выход),
  • Потребитель (1 вход, без выхода),
  • BiConsumer(2 входа, без выхода),
  • Поставщик(нет входа, 1 выход).

Если нам нужно больше *Function (или *Consumer), мы могли бы реализовать свою собственную, например, TriFunction:

@FunctionalInterface
public interface TriFunction<T, U, V, R> {
  R apply(T t, U u, V v);
}

и использовать его с 3-мя входами — и, конечно же: QuadriFunction, PentaFunction и так далее…

Если мы хотим вернуть несколько переменных в наши блоки «тогда», использование Functor всегда возможно; нам нужно просто вернуть объект Holder:

/* SAMPLE 10 */

// declare our results holder, using @RequiredArgsConstructor 
// annotation from Lombok, which generate a constructor for 
// final fields
// (check other Lombok annotations, they are very powerful: https://projectlombok.org )
@RequiredArgsConstructor
class MyResult {
  final String from;
  final String value;
}
...
// declare the Functor
final Map<String, Function<String, MyResult>> map = new HashMap<>();

// populate the Functor:
map.put("A", t -> {
  MyResult mr = new MyResult("A",t);
  return mr;
}); 
// or better using the best Java8 Lambda compact syntax
map.put("A", t -> new MyResult("A",t));
map.put("B", t -> new MyResult("B",t));
map.put("C", t -> new MyResult("C",t));
...


// finally use the Functor, returning an empty object if any matches // on Map keys; returning an empty object is also known as 
// "NullObject pattern" - check it on same design pattern tutorial
MyResult result = Optional
    .ofNullable(map.get("A").apply("someValue"))
    .orElse(new MyResult());

// instead here we want it throws a Java standard 'NoSuchElement' 
// exception if no matches on Map keys
MyResult result = Optional
    .ofNullable(map.get("a").apply("someValue"))
    .orElseThrow();

// while here we want it throws a custom exception if any matches on // Map keys
MyResult result = Optional
    .ofNullable(map.get("b").apply("someValue"))
    .orElseThrow(() -> new MyResultEmptyException("no res by 'b'));

Что ж, мы подошли к концу ЧАСТИ_1 и увидели некоторую технику, позволяющую избежать стандартного/раздражающего/необслуживаемого/и т. д. кода «If/else_if», когда мы могли бы использовать своего рода сравнение значений Enumerable.

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