Приветствую, Хабр! Предлагаю вашему вниманию небольшую пятничную статью про 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. Нюанса, при небольшом размышлении, очевидного, но — на мой вкус, всё-таки интересного.
lgorSL
Приём с наследником sealed класса в java — интересный.
Но конкретно для option можно вместо Option(null), который вернёт None, вызвать Some(null) и получить примерно те же эффекты.
Cerberuser Автор
Спасибо, проверил на тех же тестах — MatchError, естественно, не получил, но NPE — есть и
contains(null) === true
— есть. Честно говоря, немного не ожидал, почему-то думал, что в этом случае код бросит ошибку при вызове конструктора.