Приветствую, Хабр! Предлагаю вашему вниманию небольшую пятничную статью про 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. Нюанса, при небольшом размышлении, очевидного, но — на мой вкус, всё-таки интересного.
