NullPointerException прилетел из строки, в которой три точки.
def zipOf(order: Order): String = order.customer.address.zip
Сообщение пустое. Стектрейс назвал метод и строку, которые я и так знал из того, что исключение вообще случилось. Любой из трёх мог оказаться null и исключение в любом случае выглядит одинаково.
Я хотел закрыть два вопроса. Убирает ли Option этот класс ошибок на самом деле: вокруг меня так говорят, а я верю в это наполовину. И сколько он стоит, потому что я давно повторяю, что Option аллоцирует на каждый поиск и ни разу этого не померил. Всё, что ниже, это Scala 2.9.1 на jdk 7.
complete: 00-001
no address: NullPointerException, message=[null] at zipOf line 8
no customer: NullPointerException, message=[null] at zipOf line 8
no order: NullPointerException, message=[null] at zipOf line 8
Три разных дефекта приходят одной строкой вывода, повторённой трижды. Чтобы понять, какое звено оборвалось, мне пришлось вернуться в исходник и разобрать цепочку руками. Аргумент за Option именно про этот класс ошибок и он справедлив.
Тот же путь, но поля объявлены как Option:
def zipOf(order: Option[OrderO]): Option[String] =
order.flatMap(_.customer).flatMap(_.address).map(_.zip)
complete: Some(00-001)
no address: None
no customer: None
no order: None
Не бросается ничего. Три дефекта никуда не делись и программа по-прежнему не может выдать индекс, но отсутствие теперь передаётся как значение и доходит туда, где я решил его обрабатывать. Заодно компилятор не даёт мне вытащить индекс, пока я не скажу, что будет при его отсутствии.
Это верно для кода, который идёт через map и flatMap. get никуда с типа не делся:
none.get: NoSuchElementException, message=[None.get]
То же падение под другим именем класса. В сообщении хотя бы написано None.get, что лучше пустого, но гарантия распространяется только на те вызовы, которые я сам выбираю.
Где протекает
Вокруг в основном java, а java возвращает null. Так и пишется обёртка:
def zipOf(id: String): Option[String] = Some(Legacy.zip(id))
Именно так она выходит, когда пишу быстро. Компилируется и при этом неверна:
zipOf("c-1") = Some(null)
zipOf("c-1").isDefined = true
zipOf("c-1").getOrElse("none") = null
lengthOfZip("c-1") ! java.lang.NullPointerException
Interop$$anonfun$lengthOfZip$1.apply(Interop.scala:9) <- Interop$$anonfun$lengthOfZip$1.apply(Interop.scala:9) <- scala.Option.map(Option.scala:133)
Some(null) это значение, которое выглядит заполненным и не держит ничего. isDefined даёт true, getOrElse возвращает тот самый null, от которого должен был меня закрыть, а NPE переезжает внутрь лямбды в map. Встречать его там хуже, чем в исходной цепочке, потому что стек теперь идёт через библиотечный код. Всё это чинится, если писать Option(...) вместо Some(...), потому что Option.apply проверяет на null и отдаёт None.
Второй течи я не ожидал. Option это ссылка, как любая другая, поэтому он и сам может быть null:
val o: Option[String] = null = null
o.isDefined ! java.lang.NullPointerException
Interop$.main(Interop.scala:25) <- Interop.main(Interop.scala)
o.getOrElse("none") ! java.lang.NullPointerException
Interop$.main(Interop.scala:27) <- Interop.main(Interop.scala)
Компилируется без предупреждения. Я прогнал компилятор ещё раз с -deprecation, на случай, если пропустил. Единственные два предупреждения на всю сборку относятся к алиасу Integer в другом файле. Неинициализированное поле типа Option[String] держит null, а не None. Любой вызов на нём падает по-старому.
В 2.9.1 есть флаг ровно про это. -Xcheck-null предупреждает об обращении по ссылке, которая может быть null. На этих двух маленьких файлах он выдал 54 предупреждения. Три из них это настоящие разыменования на восьмой строке. Он же отмечает o.isDefined на Option, который равен null, то есть случай, которому я только что удивился. Остальное вроде стрелки на строковом литерале и label.+, потому что конкатенация строк тоже обращение по ссылке. Семнадцать из 54 это конкатенации, восемь это та самая стрелка. Вытащить нужные три из оставшихся пятидесяти одного у меня не получилось.
Что я думал про цену
Я давно повторяю, что Option обходится в аллокацию на каждый поиск. И ни разу не мерил. Таблица на 100000 элементов, 20 миллионов поисков на цикл, три прогрева, потом пять замеров с печатью медианы. Те же 2.9.1 и jdk 7, куча прибита -Xms256m -Xmx256m, чтобы compressed oops остались включёнными:
1 java null median 192 ms runs 192,192,176,201,182 15.9802 bytes/lookup
2 scala Option median 436 ms runs 453,447,436,430,412 15.9795 bytes/lookup
3 java + Option() median 188 ms runs 184,188,197,181,195 15.9795 bytes/lookup
4 scala apply, no Option median 434 ms runs 434,438,424,432,466 15.9795 bytes/lookup
Циклы 1 и 3 работают с одной и той же java.util.HashMap. Разница ровно в том, что цикл 3 заворачивает каждый результат в Option(...). 188 против 192. По трём прогонам, которые я сохранил, они укладываются в 181..189 против 186..199. Обёртка не стоит ничего заметного. Интереснее разрыв между циклами 2 и 3: 248 мс, притом что оба строят Option на каждый поиск. Разводит их scala.collection.mutable.HashMap против java.util.HashMap.
Колонка аллокаций это то, в чём я ошибался. Циклы 2, 3 и 4 аллоцируют 15.9795 байта на поиск, а цикл 1 стоит на волос в стороне, 15.9802. Эти байты уходят на ключ, а не на Some. Ключ i % SIZE упаковывается в Integer. Кэш по умолчанию держит от -128 до 127, а ключи идут от 0 до 99999. Значит 128 поисков из каждых 100000 приходят из кэша, а остальные аллоцируют по 16 байт. Это 16 × (1 − 128/100000) или 15.97952.
Цикл 4 задумывался контрольным. sm(k) отдаёт значение напрямую и слова Option в цикле нет. Он вышел таким же медленным, как цикл 2, в двух прогонах из трёх чуть медленнее. Я читал это как подтверждение, пока не выключил escape analysis:
1 java null median 195 ms runs 192,189,195,195,210 15.9802 bytes/lookup
2 scala Option median 544 ms runs 540,552,536,562,544 31.9795 bytes/lookup
3 java + Option() median 229 ms runs 209,229,239,230,227 31.9795 bytes/lookup
4 scala apply, no Option median 535 ms runs 495,545,553,535,518 31.9795 bytes/lookup
Цикл 1 держится на 15.9802, а остальные три набирают ровно по 16 байт. Цикл 4 набирает их тоже, то есть Some в нём всё-таки есть: apply зовёт get, get строит Some, а apply его разворачивает и выбрасывает. В контрольном цикле сидело то, что он должен был исключить. Заметил я это только потому, что счётчик аллокаций не сошёлся с исходником, который я сам написал.
Шестнадцать байт это один Some: заголовок в 12 байт плюс одна ссылка в 4 байта под compressed oops. При включённом escape analysis, а он включён по умолчанию, JIT понимает, что объект не покидает метод и не аллоцирует его вовсе. Цикл 3 уходит с 188 мс до 229, когда я это выключаю. Объект настоящий и он стоит времени, когда JIT не может его убрать. Просто он не создаётся.
Тот же блок закрывает второй вопрос лучше, чем моя первая пара. Циклы 2 и 3 с принудительной аллокацией строят одинаковое количество Some и аллоцируют поровну, по 31.9795 байта на поиск. Между ними всё равно 315 мс. Чем бы этот разрыв ни был, это не Option.
Чего я не проверял
Куда уходят те самые 248 мс. Я померил, что это не Option. Дальше не пошёл, так что scala.collection.mutable.HashMap остаётся в списке.
Выживает ли всё это за пределами плотного цикла. Двадцать миллионов поисков, когда больше ничего не происходит, это тепличные условия для escape analysis. В обработчике запроса, у которого сверху стек фреймов, я не знаю, останется ли Some вне кучи. Такого теста я не собрал.