Современный подход ООП к условному оператору 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.