Null подкрался незаметно: ломаем Scala Option с помощью Java

от автора

Приветствую, Хабр! Предлагаю вашему вниманию небольшую пятничную статью про Java, Scala, ненормальных программистов и нарушенные обещания.


Простые наблюдения иногда приводят к не очень простым вопросам.

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

Сочетание этих двух фактов, однако, заставило меня задаться вопросом: а как поведёт себя с точки зрения Java какой-нибудь класс, который в Scala является sealed, т.е. не может быть расширен внешним относительно его собственного файла кодом?

Декомпилированный Scala-класс в представлении художника. Источник: https://specmahina.ru/wp-content/uploads/2018/08/razobrannaya-benzopila.jpg

В качестве подопытного кролика я взял стандартный класс Option. Скормив его декомпилятору, встроенному в IntelliJ Idea, получаем примерно следующее:

// опустим импорты, они сейчас не слишком интересны public abstract class Option  implements IterableOnce, Product, Serializable {     // кучка реализаций методов     public abstract Object get();     // ещё кучка реализаций методов }

Декомпилированный код, правда, не будет являться валидным Java-кодом — проблема, аналогичная описанной здесь, к примеру, вот с таким методом:

public List toList() {     return (List)(         this.isEmpty()              ? scala.collection.immutable.Nil..MODULE$               : new colon(this.get(), scala.collection.immutable.Nil..MODULE$)     ); }

где свойство MODULE$ соответствует константе, объявленной в package object. Тем не менее, это проблема декомпилятора, использовать соответствующую скомпилированную библиотеку из Java мы сможем спокойно, верно?

Потрошим, фаршируем, запекаем…

В качестве эксперимента создадим отдельный проект на Java (со сборкой через Maven), который будет использовать Scala как provided-библиотеку — то есть, строго говоря, вообще не использовать, а только ссылаться на экспортируемые из неё типы:

<dependency>     <groupId>org.scala-lang</groupId>     <artifactId>scala-library</artifactId>     <version>2.13.1</version>     <scope>provided</scope> </dependency>

И попробуем создать класс, унаследованный от scala.Option. Idea, разумеется, знает про Scala и сразу скажет, что нам не стоит наследоваться от sealed-класса, но, тем не менее, послушно сгенерирует все необходимые методы:

package hack;  public class Hacking<T> extends scala.Option<T> {     @Override     public T get() {         return null;     }      public int productArity() {         return 0;     }      public Object productElement(int n) {         return null;     }      public boolean canEqual(Object that) {         return false;     } }

Последние три метода нам нужны из-за того, что Option реализует Product.

Собственно, мы даже не будем тут ничего менять — всё и так неплохо получилось. Запускаем mvn package — получаем крохотный, на три килобайта, jar-ник, и это значит, что Java, как и следовало ожидать, проглотила наш код, даже не заметив потенциальной подставы.

Посмотрим теперь, что с таким инструментом можно сделать в Scala.

… подаём к scala-столу

Самый простой способ использовать подобную зависимость из Scala (вернее, из SBT) — положить её в папку lib внутри проекта, что мы, собственно, и сделали; build.sbt остаётся таким, каким его сгенерировала Idea. Начинаем писать основной (и единственный) класс нашего приложения:

import hack.Hacking  object Main {   def main(args: Array[String]): Unit = {     implicit val opt: Option[String] = new Hacking()     // тут будут все эксперименты   }    private def tryDo[T](action: => T): Unit = {     try {       println(action)     } catch {       case e: Throwable => println(e.toString)     }   } }

Здесь я использовал implicit var как способ избежать повторяющегося кода при вызове будущих экспериментов.

tryDo — небольшая вспомогательная функция, чьё назначение достаточно прямолинейно: вывести в консоль либо значение переданного выражения, либо ошибку, возникшую при его вычислении. За счёт синтаксиса call-by-name мы можем передавать в tryDo не лямбду, а само выражение, чем и воспользуемся ниже.

Для начала, попробуем просто сделать match — самую простую операцию, какую только можно сделать с sealed class-ом (мы же знаем, что у нас есть объект sealed-класса, верно?)

object Main {   def main(args: Array[String]): Unit = {     // snip     tryMatch   }    private def tryDo[T](action: => T): Unit = {     // snip   }    private def tryMatch(implicit opt: Option[String]): Unit = tryDo {     opt match {       case Some(inner) => inner       case None => "None"     }   } }

Результат:

scala.MatchError: hack.Hacking

Вполне ожидаемый исход, но обратите внимание: так как Option — это sealed class, если бы мы опустили один из исходов (оставили только case Some или только case None), компилятор бы честно сгенерировал предупреждение:

[warn] $PATH/Main.scala:22:5: match may not be exhaustive. [warn] It would fail on the following input: None [warn]     opt match { [warn]     ^

Здесь же он нас ни о чём предупредить не смог, и код развалился в рантайме.

Окей, давайте теперь посмотрим на какой-нибудь случай с применением стандартных методов Option:

object Main {   def main(args: Array[String]): Unit = {     // snip     tryMap   }    private def tryDo[T](action: => T): Unit = {     // snip   }    private def tryMap(implicit opt: Option[String]): Unit =      tryDo(opt.map(_.length)) }

Результат:

java.lang.NullPointerException

Суть происходящего становится понятной, если посмотреть на реализацию метода map:

sealed abstract class Option[+A] /* extends ... */ {   // Проверка на пустоту. Тривиальная, поскольку None - это синглтон.   final def isEmpty: Boolean = this eq None   // Абстрактный метод, реализованный в нашем классе как возвращающий null.   def get: A   // И, собственно, метод map.   @inline final def map[B](f: A => B): Option[B] =     if (isEmpty) None else Some(f(this.get)) }

То есть, логика действий такая:

  • Наш объект — определённо не None. Следовательно, isEmpty вернёт false.
  • Следовательно, будет вызываться this.get, который вернёт null.
  • null передастся в функцию, в роли которой у нас — вызов метода length.
  • Вызов метода length на null приводит к NPE.

Неслабо так для языка, в котором в нормальных условиях для получения NPE надо либо специально постараться, либо использовать значения из Java без проверки? (Впрочем, строго говоря, сейчас именно этот последний факт и имел место, да…)

Ну и напоследок добавим ещё один пример:

object Main {   def main(args: Array[String]): Unit = {     // snip     tryContainsNull   }    private def tryDo[T](action: => T): Unit = {     // snip   }    private def tryContainsNull(implicit opt: Option[String]): Unit =     tryDo(opt.contains(null)) }

В языке с более серьёзной системой типов (точнее, с системой типов, более серьёзно относящейся к null) такой код мог бы просто не скомпилироваться, если бы метод contains требовал передавать в него значение не-Nullable типа. В данном случае код компилируется, но, очевидно, с обычным Option он бы выдал false — содержащееся в нём значение никогда не равняется null. Что же в нашем случае?

Результат:

true

Что, в свете сказанного выше, вполне понятно, поскольку реализация метода contains абсолютно аналогична map: !isEmpty && this.get == elem.

Заключение

Разумеется, в реальных условиях подобное можно сотворить только специально. Я ни в коем случае не призываю быть параноидальными, проверять на null всё, что получили из Option (в конце концов, этот класс для того и создан, чтобы таких проверок было поменьше) и вставлять ветки else во все подряд блоки match.

По сути, всё, для чего нужна была эта статья, — небольшой эксперимент по раскрытию одного нюанса взаимодействия разных языков на одной JVM. Нюанса, при небольшом размышлении, очевидного, но — на мой вкус, всё-таки интересного.

ссылка на оригинал статьи https://habr.com/ru/post/489328/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *