Tuesday, August 11, 2015

Maybe<Maybe<Maybe<T>>>

Многие языки имеют концепцию "нулевого указателя", унаследованную от близких к железкам языков. Ничего удивительного, что в языках вроде С/C++ где "объект" == "область памяти", nullptr, он же NULL, он же "нулевой указатель", был синонимом "отсутствия объекта".

Многие языки более высокого уровня переняли это соответствие в том или ином виде: Java (null), Obj-C (nil), python (Nothing) и т.п.

Проблема этого подхода в том, что возможность того, что возвращенное значение может быть nullptr - это часть устного контракта между написавшим и использующим метод. Например, может ли следующий код выбросить NullPointerException?
public void doWork() {
    final String some = getSome();

    if (some.isEmpty()) {
        doIfEmpty();
    } else {
        doIfContainsSomething();
    } 
}
На практике это неизвестно, т.к. зависит от деталей реализации метода getSome. Самая большая проблема в том, что даже если getSome никогда не возвращает null сейчас, нет никакой гарантии, что он не сделает этого в будущем.

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

Еще более интересно и неприятно обстоят дела на Obj-C, где разыменование nil не приводит к падению.

Дело в том, что исправление ошибки тем проще (и дешевле!) чем раньше она обнаружена. В случае Obj-C ошибка разыменования nil может привести к странному поведению значительно позднее места своего возникновения. Например: семантика сравнения такова, что если !(A > B), и !(A < B), то A == B. Если у вас есть объект, определяющий, скажем, методы сравнения, то
[nil isGreaterThan:objectToTestWith] == nil // i.e. false
[nil isLessThan:objectToTestWith] == nil    // i.e. false
[nil isEqualTo:objectToTestWith] == nil    // i.e. false
То, есть, очевидно, что семантика сравнения нарушена. Само по себе это не проблематично, до тех пор, пока на основе таких выражений не строится дальнейшая логика. И что хуже, логика, побочные эффекты которой видны длительное время, например, код базы данных. А искать причину повреждения данных несколько дней - удовольствие из малоприятных, поэтому лучше избегать таких неочевидных вещей (и лучше пусть оно закрешится сразу и проблема будет быстро найдена и исправлена).

Итак, что же такое Maybe.

Maybe - это контейнерный тип, семантика которого такова, что он может содержать или не содержать значения. То есть мы выносим "возможно нет значения" на уровень системы типов вместо того, чтобы оставить его на уровне устного контракта между разработчиками. Один из возможных способов определить этот тип выглядит так:

abstract class Maybe<T> {
   public static <T> Maybe<T> just(final T value) {
      return new Maybe<T>() {
         @Override public boolean isDefined() {
            return true;
         }

         @Override public T get() {
            return value;
         }

         @Override public T or(final T defaultValue) {
            return value;
         }
      };
   }

   @SuppressWarnings("unchecked")
   public static <T> Maybe<T> nothing() {
      return new Maybe<T>() {
         @Override public boolean isDefined() {
            return false;
         }

         @Override public T get() {
            throw new RuntimeException("Value is not defined");
         }

         @Override public T or(final T defaultValue) {
            return defaultValue;
         }
      };
   }

   public abstract boolean isDefined();
   public abstract T get();
   public abstract T or(final T defaultValue);
}
И используется он таким образом:
final Maybe<String> noString = Maybe.<String>nothing();
final Maybe<String> string = Maybe.just("string");

if (noString.isDefined()) {
    System.out.println("error!");
} else {
    System.out.println("as expected noString is nothing.");
}

System.out.println("string is: " + string.get());
System.out.println("alternative of string is: " + string.or("should not be printed"));
System.out.println("alternative of noString is: " + noString.or("alternative"));
Существуют библиотеки, имеющие реализацию похожего типа, например тип Optional в guava. Тип конвертера также имеется в этой библиотеке.

Первый и самый важный плюс, это явно выраженное намерение.

Как известно многим (увы не всем), код пишется не для машины. Код пишется для людей. И чем точнее и недвусмысленнее основная идея, заложенная в код, будет видна читающему или пользующемуся кодом в дальнейшем, тем лучше.

Без использования Maybe, этот метод может возвращать либо объект, либо null:
public String getSome();
Однако, если использовать Maybe, то метод с такой сигнатурой всегда должен вернуть объект. Чтобы иметь возможность вернуть "отсутствие объекта" нужно явно поменять сигнатуру на:
public Maybe<String> getSome();
Из новой сигнатуры четко видно, что возможна ситуация когда объекта просто нет.

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

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

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

Действительно, в изначальном примере:
public void doWork() {
    final String some = getSome();

    if (some.isEmpty()) {
        doIfEmpty();
    } else {
        doIfContainsSomething();
    } 
} 
Строка
final String some = getSome();
Перестанет компилироваться как только сигнатура метода getSome изменится. Это даст возможность тому, кто внес изменения в getSome, корректно исправить поведение всех мест, где getSome используется.

Следующий плюс это то, что, поскольку Maybe всегда не null, у него всегда можно вызывать методы.

С одной стороны
public void doWork() {
    final Maybe<String> some = getSome();

    if (some.value().isEmpty()) {
        doIfEmpty();
    } else {
        doIfContainsSomething();
    } 
}
Вызов value() без проверки isValue() все равно приведет к падению. И это, увы, неизбежно. Но, это не единственный способ использовать значение. Например, если добавить в систему интерфейс сравнения, то то, что ранее требовало кучи кода, сравнение двух потенциально нулевых объектов, теперь можно выразить одной строчкой. Привожу больше кода, но сравнение тут только последняя строка:
public interface Comparator<T> {
    boolean isEquals(T l, T r);
} 

public final class Comparsions {
    final Comparator<String> COMPARE_STRINGS_CASE_SENSITIVE = new Comparator<String>() {
            // comparator implementation
        } ;
} 
Также это позволяет использовать значение по умолчанию таким образом:
textView.setText(something.or(""));
Этот подход хорош тем, что он позволяет обойтись без проверок тогда, когда логически эти проверки не нужны. Например, имея интерфейс конвертирования, можно преобразовать один Maybe в другой без разворачивания значения:
interface Converter<T, F> {
    T convert(F f);
}

final Maybe<String> maybeString = /* ... */;

final Maybe<Integer> strlen = maybeString.convert(GET_STRING_LENGHT);
Конечно, использование типа Maybe (или Optional, или аналога), делет код несколько более многословным, однако преимущества, которые это дает, окупают эти сложности полностью.

Friday, August 7, 2015

Android View visibility DSL

Часто встречаю такой код при разработке для Android:
view.setVisibility(condition ? View.VISIBLE : View.GONE);
Использование такой конструкции приводит к тому, что это тернарное выражение дублируется в setVisibility практически каждого view, который нужно показать или спрятать. В качестве альтернативы можно использовать простенький DSL, который позволит писать что то вроде:
gone(view,
     otherView,
     moreView)
    .orVisibleIf(condition);
Плюсов сразу несколько -это позволяет управлять видимостью нескольких view одновременно, убирает многократное повторение условного выражения и в целом короче и лучше читается. А вот "определение" этого DSL:
final class ViewVisibilityTools {
    public interface OrVisible {
        void orVisibleIf(boolean condition);
    }

    public OrVisible gone(final View...views) {
        return new OrVisible() {
            @Override public void orVisibleIf(final boolean condition) {
                setVisible(views, condition, View.GONE);
            }
        };
    }

    public OrVisible invisible(final View...views) {
        return new OrVisible() {
            @Override public void orVisibleIf(final boolean condition) {
                setVisible(views, condition, View.INVISIBLE);
            }
        };
    }

    private void setVisible(final View views[],
                            final boolean condition,
                            final int alternative) {
        final int visibility;

        if (condition) visibility = View.VISIBLE;
        else visibility = alternative;

        for (final View v: views) {
            v.setVisibility(visibility);
        }
    }
}
Нужно только статически импортировать методы gone и invisible там где они нужны.