Sunday, July 18, 2021

Улучшаем условный оператор - if-then-else

Как сказал один автор на хабре, условный оператор if-then-else приобрёл свою финальную идеальную форму и его уже нельзя улучшить. Давайте из вредности попробуем что-нибудь с ним сделать.


Итак, условный стейтмент с большинстве си-подобных языках:

if условие
then если_да
else если_нет

Или его короткий вариант:

if условие
then если_да

И условное выражение (тернарный оператор):

условие ? eсли_да : если_нет

У стейтмента результата нет. У выражения результат есть. Какого он типа? Очевидно это некоторый общий тип его "да-нет" веток. 

А если часть `если_нет` не нужна? Тогда про тип результата говорить сложно. Действительно, в большинстве языков программирования тернарный оператор не может быть коротким условием. Получается парадокс: условное выражение бывает коротким условный оператор - нет.

А между тем в большинстве современных языков программирования приобретает популярность опциональный тип данных. В языках С++ и Java это optional<T>, в Haskell это maybe. Что такое значение типа optional<T>? Это или сам `T` или особое значение nothing не совпадающие ни с каким значением `T`.

И вот если допустить существование короткого условного оператора, то его результатом как раз и будет опциональный тип данных (пример на гипотетическом языке программирования):

auto positive_x = x > 0 ? x;

Читается: если `Икс` больше нуля, то результат будет икс (а иначе - ничего).

Здесь бинарный оператор `?` имеет слева логическое выражение, а справа выражение типа `T`. Результатом этого бинарного оператора является `optional<T>`

Если мы смогли выделить из тернарного оператора `?:` первую половинку, может быть можно выделить и вторую?

auto b = a : 0;

Читается: взять `а`, но если в `а` ничего нет, взять ноль.

Бинарный оператор `:` имеет слева выражение типа optional<T>, а справа - выражение типа `T`. Его результат имеет тип `T`.

Можно сказать, что оператор `?` производит `optional`, а `:` потребляет его.

Старый тернарный оператор больше не нужен: выражение `условие? eсли_да: если_нет` парсится как `(условие ? eсли_да) : если_нет`. Легко заметить, что эти два вложенных бинарных оператора ведут себя в точности так же, как оригинальный тернарный.

Итак мы разбили тернарный оператор на два бинарных. Что это нам дало:

  • Мы поддержали короткие условные - выражения. Язык стал ортогональнее, пропали запрещенные варианты.

  • Мы ничего не усложнили - не добавили новых синтаксических конструкций.

  • Мы получили два конструктора optional-значений:

    • вместо optional<decltype(x)> maybe_x{x} можно написать auto maybe_x = true?x 

    • вместо optional<decltype(x)> maybe_x{nullopt} можно написать auto maybe_x = false?x

  • Распаковка с использованием дефолтного значения тоже стала проще:

    • вместо auto a = x ? *x : 42 можно написать auto a = x : 42

  • При отсутствии данных не обязательно использовать дефолтное значение, можно бросить исключение или удариться в панику: `x : panic()`

  • Очень часто возвращая результат функции мы пишем `return condition ? result : nullopt`, теперь достаточно написать `return condition ? result;`

  • Мы можем не только комбинировать операторы `?` и `:`. Мы можем комбинировать операторы `:` между собой. Например: return current_user : get_user_from_profile() : get_default_user() : panic() Это короткое выражение попытается получить user-объект из нескольких мест, и вызовет выход из приложения если ничего не получится или, если по каким-то причинам провалится и эта затея, вернет `optional<user>{nothing}`.

Хрошо, но давайте не будем останавливаться на полпути.

Что такое значение типа `optional<T>`? Это или сам `T` или особое значение nothing, не совпадающие ни с каким значением `T`. Теперь попробуем новый трюк: что такое optional< void>? Это однобитное значение, позволяющее различить `nothing` и `void`. Вы уже заметили, что это полный синоним логического типа данных. Получается, что если в нашем языке есть тип `void` и есть и `optional<T>`, то отдельный тип `bool` нам уже не нужен. Все логические операции, например операции сравнения, возвращают `optional<void>`. А все операции принимающие логические значения теперь будут принимать любые разновидности `optional<T>`.

Что это нам дает? Оператор `?` теперь будет принимать слева не только `bool` но и вообще любой `optional<T>`, а справа - будет выражение, с результатом `X`. Результатом самого оператора `?` будет `optional<X>`.

  • При отсутствии значения он будет возвращать `optional<X>(nothing)`.

  • При наличии -

    • исполнять выражение справа, передавая ему извлеченное из `optional<T>` значение (пусть доступ к этому значению будет по имени `_`);

    • результат исполненного выражения будет упаковываться в `optional<X>`.

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

order_price = currentOrderId ? orders.findOrder(_) ? _.getPrice() : -1

  • Если `currentOrderId` существует, найти по нему `order`.

  • Если `order` нашелся, взять из него `price`.

  • Если `price` отсутствует или любой предыдущий шаг был неудачным - вернуть -1.

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

Обратите внимание, что и в C++ и в Java синтаксис позволяет обратиться к значению внутри optional без проверки на его существование

optional<int> x;
cout << *x;

var x = Optional<Integer>.empty();
System.out.println(x.get());

А в предлагаемом варианте оператор `?` откроет вам доступ к внутреннему значению только при его наличии, а оператор распаковки `:` требует указать код, который будет исполняться при отсутствии значения. Это непробиваемая защита от обращений к несуществующим значениям на уровне синтаксиса языка на этапе компиляции.

Так же обратите внимание, что мы все еще не вели ни одной новой синтаксической конструкции.

Теперь давайте заметим, что и до появления типа `optional` в мейнстрим языках уже были типы, которые позволяли закодировать "значение-или-ничего" - это указатели и числа с плавающей точкой. Пустой указатель это NULL. Пустое число это NaN. В последнее время стало модно добавлять в разные языки Null safety. Можно ли приспособить для этого наш optional if? Конечно можно! Достаточно объявить, что обычный ponter не бывает null, а если нужен именно nullable pointer, то надо его декларировать как optional<pointer>. И тогда обращаться с его содержимым будет можно только после проверки на null которая делается просто и изящно:

optional<Path*> getUserSettings(optional<User*> u) {
  return u ? homeDirectories.get(_) ? Path.make(_, "settings.json");
}

Здесь указатель, передаваемый в `homeDirectories.get` гарантированно не null (мы уже проверили его с помощью `?`), и указатель, возвращаемый из `homeDirectories.get` также проходит проверку на null прежде чем попасть в `Path.make`.

Пара слов об эффективности: Давайте внимательно посмотрим на внутреннее устройство значений типа `optional<T>`.

Это всегда значение типа T плюс какой-то способ пометить что в контейнере пусто. Некоторые типы данных сами по себе имеют недопустимые значения например:

  • в числах с плавающей точкой есть великое множество значений NaN (not-a -number),

  • нулевое значение указателя - признак Null pointer-а.

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

  • для массивов переменной длины (просто хранить особое значение в поле размера);

  • для перечислений (дополнительный невидимый вариант перечисления);

  • для структур, в которых есть хотя бы одно поле из перечисленных выше.

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

Таким образом optionals + бинарный if не только порождают чистый исходный код, но и являются примером бесплатной абстракции.

Возвращаясь к nullable === optional. Получается, что нулевое значение указателя прозрачно трактуется как optional<pointer>::nothing. Взаимодействие со старым кодом и ffi происходит с нулевым оверхедом.

Еще одна модная тенденция обвешивать условный оператор какими-нибудь вычислениями в условии, результат которых сохраняется в локальной переменной доступной в ветках условий, например на C++:

if (auto v = expression()) use(v);

Чтобы это работало, достаточно, чтобы v приводился к bool. Однако очень быстро выяснилось, что приводить один и тот же тип к логическому типу можно по разному. Например для строки иногда полезно считать как бы false пустую строку, иногда строку с текстовым значением "false", иногда к этому можно добавить строку "0" или еще как-то. Поэтому в новом C++ появился вот такой удобный вариант `if`

if (auto v = expression(); predicate(v)) use(v);

Как это работает? Вначале вычисляется `expression` его результат помещается в локальную переменную `v`, потом она передается в `predicate` который на ее основе делает `bool`, которые выбирает нужную ветку в которой эта `v` доступна. Например:

if (auto i = myMap.find(name); i != myMap.end()) use(*i);

Можно ли сделать что-то подобное с помощью бинарного `if` и `optional`? Легко:

predicate(expression()) ? use(_)

Где: `expression` по-прежнему отдает значение `T`, `predicate` анализирует его и превращает в `optional<T>` со значением внутри, a `if` при удачном решении предиката передает распакованное из `optional` значение `T` в `use`. Пример:

is_appropriate(get_user_name(user_id))
  ? echo("user name is:" + _)
  : echo("username is so @#$ing @#$it that I can't even say it");

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

Или вот еще: Optional также может использоваться для безопасного доступа к контейнерам с ключами или индексами. Пусть container<key, value>::operator[] не бросается исключениями, а возвращает optional<value>. Тогда проверка на присутствие ключа или выход за пределы массива может быть явно обработана в удобном месте с использованием лаконичного синтаксиса:

myVector[index] ? _++;   // Если вектор содержит элемент по индексу, увеличить его
myVector[index] ? _++ : panic();  // Второй вариант .. а иначе завершить приложение.
return myMap[myKey] : defaultValue;  // Вернуть элемент контейнера или значение по умолчанию.
(myMap[myKey] : myMap.add(key, 0))++;  // Найти или вставить элемент и увеличить его.

Последний на сегодня не рассмотренный вопрос - optional<optional<T>> и вообще optional<...optional<T>...>. Такие типы возникают, например, когда у нас есть контейнер, хранящий optionals, и мы берем из него элемент по индексу:

vector<bool> v;  // хранит optional<void>
auto x = v[5];  // возвращает optional<bool> aka optional<optional<void>>

Так что такие типы вполне легальны. Кстати, их внутреннее устройство и занимаемая память для любых разумных уровней вложенности аналогичны таковым для просто optional: для указателей мы можем использовать первые N после нуля значений (все равно первые страницы адресного пространства недоступны для приложений). Аналогично в числах с плавающей точкой у нас есть 2^52 разных NaN значений. Мы можем использовать эти значения как маркеры nothing, just(nothing), just(just(nothing)) и т. д.

При этом, при правильно подобранных числовых значениях, преобразования optional<optional<T>> ↔ optional<T> ↔ T все еще остаются no-op.


В сухом остатке:

Простым разбиением тернарного оператора `?:` на два бинарных и использованием `optional<T>` вместо `bool` мы без изменения синтаксиса языка получаем:

  • Ортогональность синтаксических конструкций: больше нет странного тернарного оператора и нет запрета на короткое условие в выражении.

  • Встроенную в язык легковесную null safety. Объединение понятий nullable и optional.

  • Index safety.

  • Легкое конструирование и распаковка optional значений.

  • Комбинирование операторов `?`, что дает нам безопасные проверяемые цепочки преобразований значений, вызовов методов и доступов к полям с пошаговой проверкой валидности.

  • Комбинирование операторов `:` дает последовательные попытки отката к дефолтным значениям, восстановления и выхода из проблемных ситуаций.

  • Легковесная конструкция создания значения и переходов по веткам в зависимости от результатов предикатов над ним с передачей значения в нужную ветку.

  • Прозрачное взаимодействие с легаси и внешним кодом на других языках.

  • Легковесность обеспечивается не только в синтаксисе, но и во внутреннем представлении и бесплатности почти всех конверсий над optional типом данных.

Кстати, optional можно использовать для организации циклов, что позволит превратить циклы в выражения. Но это уже другая история.

PS. `and` это не `if`, a `or` это не `else`.

A && B  A ? B : A  //  `&&` не упаковывает результат B в optional
A || B A ? A : B     // `||` не распаковывает результат условия из optional


No comments:

Post a Comment