1,
2,
3,
4,
5,
6,
7,
8,
9,
10В этой части мы ещё тоже не дойдём до апликативного стиля - но обсудим альтернативы и затронем монады.
Чтобы справиться с нетотальностью функций, с которыми мы имеет дело, есть несколько альтернатив.
Рассмотрим очевидные:
-
null- exceptions
- null object
-
Option1. null
В принципе, джава унаследовала
null из си, и жила не тужила, пока не появились ещё дженерики, и стало как-то неудобно. До монад далеко, но уже неудобно.
То есть, вернуть
null - да никаких проблем, кто только не возвращает
null; и принять
null в качестве параметра тоже милое дело, дескать, нетути этого вашего параметра, ну не шмогла я.
И люди вполне спокойно пишут функции, которые проверяют параметр на
null и если что, возвращают
null; чем не
Option. Да тем, что всё это нужно явно прописывать.
String countryName(User user) {
if (user == null) return null;
String phone = user.getPhone();
if (phone == null) return null;
String countryCode = PSTN.extractCountryCode(phone);
if (countryCode == null) return null;
Country country = Countries.findByCode(countryCode);
if (country == null) return null;
return country.getName();
Этот же код можно написать более прилично (10x
sab123):
String countryName(String userId) {
User user; Phone phone; String cc; Country country;
return userid != null
&& (user = db.findUser(userid)) != null
&& (phone = user.getPhone) != null
&& (cc = phone.getCountryCode) != null
&& (country = Countries.findByCode(cc)) != null ? country.getName() : null
}
Ниоткуда не известно, что функция, получающая
null, волшебным образом должна вернуть
null, или что метод, вызванный на объекте
null, вернёт
null, а не, к примеру, 0, если хотим длину. Поэтому и пишут методы
isEmpty(String s) { return s == null ? 0 : s.isEmpty(); }.
А забыли один раз - и наш
null куда-то протёк внутрь, в какую-нибудь библиотечную функцию, та дальше передала - и потом разбирайся, в чём вообще дело. В реальности-то в джаве принято возвращать
null на выходе, но не проверять на входе.
Но в принципе, если нам попался
null вместо осмысленного значения, и нам надо с него что-то взять, то мы получим
NPE; исключение - более-менее монада, в отличие от
null.
2. exceptions
Если б мы были циниками, а не серьёзным джава-программистами, озабоченными производительностью компьютера, наносекундами и килобайтами, мы б могли писать так:
String countryName(User user) {
try {
return Countries.findByCode(PSTN.extractCountryCode(user.getPhone()));
} catch (NPE npe) {
return null;
}
}
Согласитесь, выглядит не очень прилично - но гораздо более осмысленно. К тому же, вполне монадично - если
NPE на нас не бросится, то вернём "данное", ну а если бросится, так только одно. Может быть, так и надо на джаве писать.
Тут только такая проблема, что мы не знаем, откуда прибежал этот
NPE - может, вовсе и не из-за того, что у нас данных нету; для прототипа такой код годится, а в реальной программе вряд ли. Но для прототипа годится.
Хоть и выглядит дико, но у нас тут проступают черты вполне логичного приёмчика - вычисления делаются только в случае, если данные имеются, а исключительный случай отделяется от нормального там, где мы "вылазим из монады".
3. Null Object Pattern
Истинный джавщик не может без паттерна. Null Object горячо рекомендуют наши кумиры, Нил Гафтер и Джош Блок. Имеет смысл - если ничего не нашли в базе, возвращаем не страшный null, а его представителя в данном типе;
NullUser
, у которого есть
NullPhone
у которого есть пустой номер (""), по которому PSTN возвращает пустой код страны (или код пустой страны... хм, я знаю одну пустую страну, называется
Атолл Диего Гарсиа - код страны есть, но нет ни одного опубликованного телефона; все на кораблях. Но мы введём пустую страну, NullCountry, и тогда Всё Будет Правильно.
Смысл этой операции такой, что теперь в коде мы можем предполагать тотально определённые функции и не отвлекаться на исключения.
Функция
String countryName(User user) {
return Countries.findByCode(PSTN.extractCountryCode(user.getPhone()));
}
вернёт пустую строку.
В сущности что мы сделали - это каждый тип пополнили своим отдельным
None/NAN/Not_a_Country/Not_a_Phone/Not_a_Pipe. Практически монада Option, но имплементированная для каждого типа отдельно, вручную; для различения ещё нужно добавить метод
boolean isValid(), который будет возвращать
true для нормальных значений и
false для нашего None.
4. Option
Ну или можно сделать умственное усилие и добавить интерфейс
Option<T>, у которого будет две имплементации,
None и
Some(T value).
Она монада потому, что имеются две
стандартные операции:
единица монады, строящая по значению
T x значение
new Some<T>(x) - т.е. просто конструктор, и
монадное умножение, часто называемое операцией
flatten - если есть
Option<Option<T>>, то из него получается
Option<T>.
Монады бывают разной степени сложности,
Option из них самая простая. Её и плющить особенно легко:
None превращается в
None,
Some(None) превращается в
None,
Some(Some(x)) превращается в
Some(x).
Сравните с плющеньем списка:
List<List<T>> → List<T> - в данном случае мы пробегаем по списку списков и возвращаем список всего, что нам попалось по дороге:
<T> List<T> flatten(List<List<T>> lol) {
List<T> result = new // something
for (List<T> list : lol) {
for (T element : list) {
result.add(element);
}
}
return result;
}
На Скале ту же операцию можно выразить проще:
def flatten[T] (lol: List[List[T]]): List[T] = {
val result = new scala.collection.mutable.// какой-нибудь список подходящего типа
for (list <- lol
element <- list) result.add(element)
result
}
Ну или ещё проще:
def flatten[T] (lol: List[List[T]]) = lol.flatten С помощью
Option с частичными функциями обращаться проще - вместо списка, вместо
null, вместо исключения мы просто возвращаем всегда
Option; а чтобы применить функцию к "содержимому
Option", мы можем использовать цикл:
for (User user : db.findUser(userid)) {
for (Phone phone : user.getPhone()) {
for (String countryCode: phone.getCountryCode()) {
for (Country country: Countries.findByCode(countryCode)) {
System.out.println("User " + user + " is from " + country);
}}}}
Чтобы можно было писать цикл, нужно вот что:
Option<T> implements Iterable<T> - ну это несложно. None всегда возвращает пустой
Iterable, а
Some возвращает одноэлементный
Iterable.
На скале такая конструкция записывается в виде одного цикла:
for (user <- db.findUser(userid)
phone <- user.getPhone
cc <- phone.getCountryCode
country <- Countries.findByCode(cc)
) println("User " + user + " is from " + country)
За сценой такой хитрый цикл разворачивается в последовательность сплющиваний.
Нет, на самом деле надо добавить ещё одну операцию, преобразование,
fmapЕсли у нас есть функция
<A,B> B f(A a) {...}, то монада... ну, скажем, та же
Option, определяет функцию
<A, B> Option<B> Option_f(Option<A> aOpt) {
for (a : aOpt) { return new Some<B>(f(a)); }
return None;
}
На скале это выглядит практически так же:
def Option_f[A,B](aOpt: Option[A]) = {
for (a <- aOpt) return Some(f(a))
None
}
Или просто
def Option_f[A,B](aOpt: Option[A]) = aOpt map fДумаю, Вы можете написать аналогичную операцию для монады списка (
List).
Монады помогают примирить реальный мир с его, э, исключениями и идеальный мир функций, который мы, теоретически, любим в компьютерах.
К сожалению, и с монадами нас подстерегают неприятности, о которых я расскажу в следующей части. Но не думайте, что без монад всё здорово и можно обойтись if-ами. Напротив; с if-ами вы просто не замечаете проблем в вашем коде - они парят как коршуны где-то над головами, а мы как мыши суетимся, ищем, где бы тут соптимизировать да все случаи учесть. "Книги про паттерны" не помогают.