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

    Комментарии 11

      +1

      Приём с наследником sealed класса в java — интересный.
      Но конкретно для option можно вместо Option(null), который вернёт None, вызвать Some(null) и получить примерно те же эффекты.

        0

        Спасибо, проверил на тех же тестах — MatchError, естественно, не получил, но NPE — есть и contains(null) === true — есть. Честно говоря, немного не ожидал, почему-то думал, что в этом случае код бросит ошибку при вызове конструктора.

        0

        Я, наверное, чего-то не понимаю, но почему sealed-класс в Scala не является final-классом в Java? Из-за этого ведь все проблемы.

          0

          А в этом случае JVM разве не ругнётся на то, что от него кто-то отнаследовался в Scala?

            0

            А разве в Scala можно отнаследоваться от Option? Статья же говорит, что нет, ведь он sealed.

              0

              Извне — нельзя, но внутри того же пакета у него есть наследники — case class Some и case object None. Я пытаюсь понять, если при компиляции Scala объявит Option как final, примет ли JVM наличие Some и None?

                0

                Ах, вот оно в чём дело… Я со Scala'ой не знаком, потому мне и непонятно было, почему sealed-классы не былы сделаны final'ьными. Но раз есть (приватные?) наследники, выходит, по-другому сделать было нельзя.

                  0

                  Там как раз в том и суть, да, что они есть (и даже не приватные). С точки зрения Scala, sealed-класс — это такой, все наследники которого известны во время компиляции: поэтому он, собственно, и может предупредить о потенциальном MatchError.

          +2

          Что интересно, sealed классы в kotlin'e таким образом не сломаешь — конструктор у базового класса помечен как synthetic, что делает невозможным его вызов из Java (да и других jvm языков) без применения особой уличной магии.
          Почему в скале не сделали так-же — непонятно, вполне очевидная штука.


          kotlin
          sealed class Option {
              class Some<out T>(val value: T) : Option()
              object None : Option()
          }

          java
          import kotlin.jvm.internal.DefaultConstructorMarker;
          
          public abstract class Option {
              private Option() {
              }
          
              public /* synthetic */ Option(DefaultConstructorMarker $constructor_marker) {
                  this();
              }
          
              public static final class Some<T>
              extends Option {
                  private final T value;
          
                  public final T getValue() {
                      return this.value;
                  }
          
                  public Some(T value) {
                      super(null);
                      this.value = value;
                  }
              }
          
              public static final class None
              extends Option {
                  public static final None INSTANCE;
          
                  private None() {
                      super(null);
                  }
          
                  static {
                      None none;
                      INSTANCE = none = new None();
                  }
              }
          
          }
            0

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

              +1

              Это не меняет ситуации с данной конкретной проблемой. Разработчики jvm языков вполне осведомлены о наличии synthetic, который был с лохматых времён (в 1.5 точно был, возможно даже раньше, если не с 1.0), и активно им пользуются в других местах.
              Добавление synthetic не ломает abi (разве что api для случаев, описанных в статье, но кого волнует совместимость с теми, кто в обход скалы пытается отнаследоваться от sealed?), его вполне можно добавить хоть прямо сейчас в компилятор.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое