Сообщений 8    Оценка 380        Оценить  
Система Orphus

Вывод NotNull-аннотаций по байткоду Java

Автор: Ключников Илья Григорьевич
Опубликовано: 29.04.2014
Исправлено: 10.12.2016
Версия текста: 1.1
Проблема нулевых ссылок
Расширение системы типов и проблемы интероперабельности
Постановка задачи
Подзадача распознавания проверок
Вывод гарантированно корректного подмножества @NotNull-аннотаций
Байткод Java и интересующие нас инструкции
Описание алгоритма
Построение дерева конфигураций
Распознавание циклов – построение конечных деревьев конфигураций
Аппроксимация поведения метода по дереву конфигураций и вывод аннотации
Реализация алгоритма
Построение графа потока управления
Абстрактные значения. Создание стартового фрейма
Исполнение на абстрактных значениях, отслеживание разыменовывания параметра
Аппроксимация поведения метода и вывод @NotNull-аннотации
Заключение

Проблема нулевых ссылок

В большинстве наиболее распространенных языков программирования со статической типизацией (включая Java) есть нулевые ссылки (в Java это null), разыменование которых приводит к ошибкам времени выполнения. Автор нулевых ссылок Тони Хоар признал их существование "ошибкой на миллиард долларов" (Tony Hoare. Null References: The Billion Dollar Mistake http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare).

Ирония ситуации заключается в том, что цель статических проверок – гарантировать отсутствие ошибок времени выполнения, если код компилируется, и статические проверки в большинстве языков не покрывают одну из самых распространенных ошибок – разыменование нулевой ссылки.

В Java разыменование нулевой ссылки приводит к NullPointerException (NPE). Другой распространенной и близкой по духу ошибкой было ClassCastException, вызванное отсутствием типовых параметров – эта проблема была решена (по мнению многих, отнюдь не самым элегантным образом) в Java 1.5.

Расширение системы типов и проблемы интероперабельности

Интуитивно понятно, что вероятность того, что в будущем в Java проблема NPE будет решаться "из коробки" элегантным и надежным способом, невелика, – слишком тяжело наследие legacy-кода. Поэтому появляются альтернативные решения.

Основных решений два.

Детали и тонкости этих решений описаны в конце статьи. Однако высока вероятность, что рано или поздно, в большом проекте, возникнет необходимость использовать существующие библиотеки, написанные на старой доброй Java. И тогда проблема нулевых ссылок возникает на границе взаимодействия двух миров – вашего нового чистого кода и библиотек Java. В общем случае сделать использование библиотек java полностью безопасным элегантным способом (без введения дополнительных конструкций, сущностей и рутинной работы) практически невозможно. Однако можно сделать такое использование более безопасным автоматически, без усилий со стороны пользователя.

Постановка задачи

Будем аннотировать параметр как @NotNull, если при любых условиях при передаче нулевого аргумента в данный параметр метод не может нормально завершиться. С точки зрения использования, инструмент, работающий с такими аннотациями (а с такими аннотациями умеет работать IDEA), должен предупреждать пользователя об ошибках, если в такой параметр передается null или значение, которое может быть null (@Nullable значение).

Здесь существенна фраза "при любых условиях". Автоматически выведенные аннотации не должны угадывать намерения автора, – они должны помогать пользоваться библиотекой, но не запрещать пользоваться ей. Ситуация, когда аннотация может помешать пользоваться библиотекой - это сужение ее реальной области определений или области значений.

Рассмотрим некоторый метод с сигнатурой

      public
      void foo(Object x, Object y)
{
  ...
}

Мы считаем, что null, переданный в параметр x, принадлежит области определения, если можно создать такое состояние системы и так подобрать остальные параметры, что метод завершится в нормальном режиме. Будем считать, что автоматически выведенная аннотация корректна, если она не сужает область определения метода. Поэтому естественное требование – пользователь не должен получать ложных предупреждений. Рассмотрим несколько простых примеров.

Корректная аннотация:

      void loadConfig(@NotNull View view)
{
  File f = getConfigFile();
  if (f != null)
  {
    view.loadConfigFromFile(f);
  }
  else
  {
    view.loadDefaultConfig();
  }
}

Некорректная аннотация:

      void saveConfig(@NotNull View view, File defaultConfigFile)
{
  File f = getConfigFile();
  if (f == null)
  {
    f = defaultConfigFile;
  }
  if (f != null)
  {
    view.saveConfigToFile(f);
  }
}

Интуитивно может показаться, что нужно проаннотировать view как @NotNull. Однако мы ничего не знаем о принципах устройства библиотеки с данным методом. Может быть, здесь действует негласное соглашение, что когда конфигурацию некуда сохранить, нормально освободить (обнулить) view еще до вызова этого метода. Если мы проаннотируем view как @NotNull, то мы сломаем один из вариантов потенциального правильного использования библиотеки – программист может либо получить ложный warning, либо потерять возможность вызвать метод с легальным null.

Далее мы будем называть аннотацию @NotNull на параметре некорректной, если метод может нормально завершиться при передаче null в данный параметр.

Задача: автоматически вывести как можно больше корректных @NotNull аннотаций на параметрах.

Подзадача распознавания проверок

Во многих реальных Java-библиотеках есть ранние проверки того, что параметр не равен null, в следующем виде:

        if (array == null)
{
  thrownew IllegalArgumentException("array is null");
}

Мы хотим обрабатывать, в том числе, и такие случаи.

Иногда эта проверка может иметь более скрытую форму:

        if (!x instance of Serializable)
{
  thrownew IllegalArgumentException("x is not Serialazable");
}

В данном случае, если x = null, исполнение завершится аварийно.

Мы хотим, по возможности, обрабатывать и такие случаи.

Вывод гарантированно корректного подмножества @NotNull-аннотаций

В общем случае невозможно автоматически вывести все корректные аннотация. В двух словах, эта невозможность сводится к неразрешимости в общем случае проблемы останова.

Наш алгоритм выводит лишь подмножество всех корректных аннотаций. Мы сделаем простое упрощение, которое приведет к потере точности, но не приведет к потере корректности.

Байткод Java и интересующие нас инструкции

Байткод для каждого метода анализируется отдельно (технически, мы будем выполнять интерпроцедурный анализ). Рассматривается случай, когда библиотека уже частично проаннотирована – то есть для некоторых методов мы уже имеется набор @NotNull-аннотаций для параметров.

Вкратце объясним, как устроен байткод, и как работает JVM с точки зрения нашей задачи.

Более формальное описание можно прочесть здесь

JVM – стековая виртуальная машина, для вызова процедур (методов) используется стек вызововов. С выполнением каждого метода связан фрейм, в котором хранятся локальные переменные, стек операндов и указатель на текущую инструкцию. При вызове метода текущий фрейм помещается в стек фреймов, запоминается "адрес возврата", создается новый фрейм, инициализируется в соответствии с аргументами вызова, и управление передается инструкциям вызываемого метода с новым фреймом в качестве текущего. При завершении метода предыдущий фрейм восстанавливается, возвращаемое значение помещается в стек операндов, и управление передается инструкции, сохраненной в адресе возврата.

В java bytecode более 200 инструкций для операций с целыми числами, числами с плавающей точной, с массивами, записи и чтения полей объектов, вызовов методов – статических и методов объектов и т.д.

В контексте решаемой задачи нас интересуют такие инструкции байткода, исполнение которых может привести к NPE. Вот полный список таких инструкций с описанием ситуаций, когда точно произойдет NPE.

Из перечисленного выше набора опасных ситуаций нас интересуют те, которые связаны с обрабатываемым параметром.

Описание алгоритма

В данном разделе описывается суть алгоритма. Детали реализации описываются в следующем разделе.

Для работы с байткодом мы используем библиотеку ASM (http://asm.ow2.org/). (Кстати, документация библиотеки ASM очень хороша для знакомства с принципами java bytecode.)

Суть алгоритма – рассмотреть все возможные пути исполнения байткода данного метода и, если каждый путь исполнения завершается NPE-ошибкой на данном параметре, пометить данный параметр как @NotNull.

Такой анализ проводится для каждого параметра независимо. То есть, если у метода, скажем, 3 параметра, анализ проводится 3 раза – для каждого параметра. Для решения поставленной задачи достаточно абстрагироваться от реальных значений, с которыми работает виртуальная машина Java, и разделить все значения на два класса абстрактных значений – ParamValue и BasicValue. ParamValue – это значение параметра, BasicValue – все остальные значения (необходимое нам org.objectweb.asm.tree.analysis.BasicValue уже реализовано в ASM). Таким образом, мы переходим от исполнения кода на конкретных значениях к исполнению (интерпретации) кода на абстрактных значениях.

С помощью библиотеки ASM легко строится граф потока управления для метода. Чтобы проанализировать все возможные пути исполнения байткода данного метода достаточно проследить все пути исполнения по графу потока управления данного метода. Естественно, что если в графе потока управления есть циклы, то в общем случае число путей в этом графе бесконечно. Однако, для наших целей все пути, в том числе и бесконечные, можно представить в конечном виде.

Построение дерева конфигураций

Будем строить дерево всех возможных путей в графе потока управления. В узлах такого дерева будут находиться конфигурации, однозначно описывающие интересующее нас состояние. Конфигурация представляется парой (insnIndex, frame), где insnIndex — номер текущей инструкции, а frame – массив из абстрактных значений переменных и операндов (для состояния фрейма нам подойдет org.objectweb.asm.tree.analysis.Frame). Будем называть такое дерево деревом конфигураций.

Дерево строится сверху вниз, в самом начале в корень дерева помещается конфигурация (0, startFrame), в startFrame все значения, кроме интересующего нас параметра – BasicValue, а интересующий нас параметр – ParamValue.

Начинаем достраивать это дерево конфигураций – по insnIndex получаем инструкцию байткода, исполняем ее над frame (над абстрактными значениями) и получаем обновленное состояние фрейма nextFrame. Если текущая инструкция приводит к завершению исполнения метода (return или throw), то конфигурация становится листом дерева конфигураций. Иначе рассматриваем все возможные переходы (из графа потока управления). Для каждого перехода к инструкции с номером nextInsnIndex конструируем новую конфигурацию (nextInsnIndex, nextFrame) и добавляем ее в качестве дочернего узла к текущему. Помимо прочего, будем отслеживать, происходит ли при исполнении инструкции разыменовывание ParamValue (возникает ли опасная ситуация, если в параметр передан null). Также будем отслеживать ветви в дереве конфигураций, на которые мы попадаем, если значение ParamValue – null.

Распознавание циклов – построение конечных деревьев конфигураций

В общем случае при наличии в методе циклов дерево конфигураций будет бесконечным. Приятным фактом является то, что количество всех возможных конфигураций нашего дерева конечно. Действительно, вариантов для первой компоненты конфигураций конечное число – число инструкций в данном методе ограничено. Количество же значений frame - тоже конечное число, 2^n, где n – размер массива значений во фрейме (локальные переменные и операнды).

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

Введем упорядочение ≤ на абстрактных значениях («быть частным случаем») следующим образом:

Пусть frame[i] - значение в i-ом слоте фрейма. Будем считать, что фрейм frame2 является частным случаем фрейма frame1 (и записывать это как frame2 ≤ frame1), если для всех индексов слотов выполняется frame2[i] ≤ frame1[i]. Введем упорядочение "быть частным случаем" и на конфигурациях, конфигурация (insnIndex2, frame2) ≤ (insnIndex1, frame1) если:

Если при построении дерева конфигураций мы встречаем конфигурацию conf2 и на пути от данной конфигурации до корня дерева конфигураций есть conf1, такая, что conf2 ≤ conf1, то не будем достраивать поддерево для conf2, а превратим эту конфигурацию в лист со специальным значением CYCLE. Это значит, что мы просто переходим в начало некоторого цикла.

Дерево конфигураций, построенное таким образом, будет конечным всегда.

Аппроксимация поведения метода по дереву конфигураций и вывод аннотации

Пусть у нас есть полностью построенное дерево конфигураций. Проаннотируем листы дерева конфигураций метками со следующими значениями.

Далее размечаем все остальные (нелистовые) конфигурации (узлы) следующим образом. Если у конфигурации только одна дочерняя конфигурация, то размечаем ее так же, как и дочернюю конфигурацию. Если же у конфигурации несколько дочерних конфигураций, то метка есть «перемножение» дочерних меток в соответствии с приведенной ниже таблицей перемножения меток.

с1\c2

RETURN

NPE

ERROR

CYCLE

RETURN

RETURN

RETURN

RETURN

RETURN

NPE

RETURN

NPE

NPE

NPE

ERROR

RETURN

NPE

ERROR

ERROR

CYCLE

RETURN

NPE

ERROR

CYCLE

Данный процесс повторяется рекурсивно для всех конфигураций (веток дерева). В итоге мы вычисляем метку для корневого узла дерева конфигураций.

Метка корневого узла интерпретируется следующим образом:

Параметр аннотируется как @NotNull, если корневой узел дерева конфигураций помечен как NPE.

Реализация алгоритма

Перейдем теперь к реализации описанного выше алгоритма (https://github.com/ilya-klyuchnikov/kanva-micro). Изначально задача возникла для аннотирования java-библиотек для их использования из кода, написанного на языке Kotlin. Реализация алгоритма написана на языке Kotlin (http://kotlin.jetbrains.org/).

Технически полный цикл аннотирования библиотеки состоит из следующих шагов:

Все, кроме выделенного, легко реализуется. Мы не будем подробно разбирать, как реализованы эти шаги – заинтересованный читатель может посмотреть, как это сделано, в коде проекта. С технической точки зрения интерес представляет реализация вывода (если точнее, одной итерации) аннотации на параметре. В библиотеке ASM уже реализовано много необходимых утилит, - наша реализация максимально переиспользует то, что уже есть в ASM.

В дальнейшем изложении упростим одну техническую деталь, которая не существенна с точки зрения сути алгоритма, но при реализации выливается в необходимость кропотливо учитывать особенности байткода и работы JVM, усложнение структур данных и т.д. Эта техническая деталь – обработка исключений. Когда в блоке try перехватывается исключение, то перед переходом к началу блока catch JVM очищает стек операндов. В дальнейшем мы предполагаем, что в анализируемом методе нет блоков catch. (Все детали учета исключений можно посмотреть в коде проекта https://github.com/ilya-klyuchnikov/kanva-micro.)

Итак, рассмотрим подробно реализацию вывода аннотации для параметра по шагам.

Построение графа потока управления

В библиотеке ASM есть два основных способа обработки байткода:

В ASM есть утилиты, позволяющие анализировать AST байткода и абстрагирующие низкоуровневые моменты работы с байткодом. Одной из таких утилит является org.objectweb.asm.tree.analysis.Analyzer – утилита для семантического анализа байткода с помощью интерпретатора байткода.

ПРИМЕЧАНИЕ

В действительности Analyzer производит абстрактную интерпретацию байткода на абстрактных значениях, порождаемых интерпретатором – неподвижную точку на графе потока управления.

С помощью Analyzer и самого простого интерпретатора org.objectweb.asm.tree.analysis.BasicInterpreter строится граф потока управления внутри метода. (src/analysis/controlFlowGraph.kt)

        package kanva.analysis

import org.objectweb.asm.tree.MethodNode
import org.objectweb.asm.tree.analysis.Analyzer
import org.objectweb.asm.tree.analysis.BasicValue
import org.objectweb.asm.tree.analysis.BasicInterpreter

import kanva.declarations.Method
import kanva.graphs.*

fun buildCFG(method: Method, methodNode: MethodNode): Graph<Int> =
    ControlFlowBuilder().buildCFG(method, methodNode)

privateclass ControlFlowBuilder(): Analyzer<BasicValue>(BasicInterpreter())
{
  privatevar methodWithExceptions = falseprivateclass CfgBuilder: GraphBuilder<Int, Int, Graph<Int>>(true)
  {
    override fun newNode(data: Int) = Node<Int>(data)
    override fun newGraph() = Graph<Int>(true)
  }

  privatevar builder = CfgBuilder()

  fun buildCFG(method: Method, methodNode: MethodNode): Graph<Int>
  {
    builder = CfgBuilder()
    analyze(method.declaringClass.internal, methodNode)
    return builder.graph
  }

  overrideprotectedfun newControlFlowEdge(insn: Int, successor: Int)
  {
    val fromNode = builder.getOrCreateNode(insn)
    val toNode = builder.getOrCreateNode(successor)
    builder.getOrCreateEdge(fromNode, toNode)
  }
}

Обнаруживая при анализе байткода переход между обычными инструкциями, Analyzer вызывает newControlFlowEdge.

Функция fun buildCFG(method: Method, methodNode: MethodNode): Graph<Int> строит граф, в узлах которого находятся номера инструкций метода, а дуги графа – возможные переходы между инструкциями. Объект класса Method содержит метаинформацию о том, в каком классе определен рассматриваемый метод, какие у него модификаторы и т.д.

Абстрактные значения. Создание стартового фрейма

Как уже было сказано, внутри библиотеки ASM уже есть BasicInterpreter, который умеет исполнять инструкции байткода над абстрактными значениями класса BasicValue. Все, что известно о BasicValue – это то, какой тип (класс) у значения. Таким образом, исполняя байткод с помощью BasicInterpreter, мы получаем информацию о том, значение какого класса лежит в каждом слоте фрейма. BasicValue из ASM подходит для абстрактного значения "все, кроме рассматриваемого параметра". Введем явным образом тип для представления значения интересующего нас параметра:

        class ParamValue(tp: Type?): BasicValue(tp)

Для распознавания проверок на instanceOf нам потребуется следующее абстрактное значение:

        class InstanceOfCheckValue(tp: Type?): BasicValue(tp)

Нам повезло с библиотекой ASM – почти всю необходимую работу по исполнению инструкций байткода над значениями BasicValue, ParamValue и InstanceOfCheckValue класс BasicInterpreter может делать "из коробки". Нам необходимо лишь правильно породить такие значения в правильных местах, остальное произойдет автоматически.

Один из основных моментов – создание стартового фрейма для анализа. Вот код, создающий стартовый фрейм для вывода аннотации для параметра с индексом paramIndex.

        fun createStartFrame(
    method: Method,
    methodNode: MethodNode,
    paramIndex: Int): Frame<BasicValue>
{
  val frame = Frame<BasicValue>(methodNode.maxLocals, methodNode.maxStack)
  val returnType = Type.getReturnType(methodNode.desc)
  val returnValue =
    if (returnType == Type.VOID_TYPE) nullelse BasicValue(returnType)
  frame.setReturn(returnValue)
  val args = Type.getArgumentTypes(methodNode.desc)
  var local = 0
  if (!method.access.isStatic())
  {
    val thisValue=
      BasicValue(Type.getObjectType(method.declaringClass.internal))
    frame.setLocal(local++, thisValue)
  }
  for (i in 0..args.size - 1)
  {
    val value =
      if (i == paramIndex)
      ParamValue(args[i])
      else
      BasicValue(args[i])
    frame.setLocal(local++, value)
    if (args[i].getSize() == 2)
    {
      frame.setLocal(local++, BasicValue.UNINITIALIZED_VALUE)
    }
  }
  while (local < methodNode.maxLocals)
  {
    frame.setLocal(local++, BasicValue.UNINITIALIZED_VALUE)
  }
  return frame
}

Инициализация происходит в соответствии с тем, как работает JVM. Если метод не статический (метод экземпляра), то в начало массива локальных переменных помещается this. Затем в массив локальных переменных помещаются значения параметров. Здесь и выполняется основная логика – интересующий нас параметр представляется как ParamValue, остальные параметры – как BasicValue. Остальные слоты для локальных переменных заполняются непроинициализированными значениями. Также учитывается, что значения типов long и double занимают два слота.

Исполнение на абстрактных значениях, отслеживание разыменовывания параметра

В BasicInterpreter уже реализовано почти все, что нам требуется. Значение ParamValue порождается при конструировании стартового фрейма.

Вот список того, чего нам не хватает:

Вот полный код интерпретатора, делающий все необходимое:

        class ParamSpyInterpreter(val context: Context): BasicInterpreter()
{
  var dereferenced = falsefun reset()
  {
    dereferenced = false
  }

  public override fun unaryOperation(
    insn: AbstractInsnNode, value: BasicValue
  ): BasicValue?
  {
    val opCode = insn.getOpcode()
    if (value is ParamValue)
    {
      when (opCode)
      {
        GETFIELD,
        ARRAYLENGTH,
        MONITORENTER ->
          dereferenced = true
      }
    }
    if (opCode == CHECKCAST && value is ParamValue)
    {
      val desc = ((insn as TypeInsnNode)).desc
      return ParamValue(Type.getObjectType(desc))
    }
    if (opCode == INSTANCEOF && value is ParamValue)
    {
      return InstanceOfCheckValue(Type.INT_TYPE)
    }
    returnsuper.unaryOperation(insn, value);
  }

  public override fun binaryOperation(
    insn: AbstractInsnNode, v1: BasicValue, v2: BasicValue
  ): BasicValue?
  {
    val opCode = insn.getOpcode()
    if (v1 is ParamValue)
    {
      when (opCode)
      {
        IALOAD,
        LALOAD,
        FALOAD,
        DALOAD,
        AALOAD,
        BALOAD,
        CALOAD,
        SALOAD,
        PUTFIELD ->
          dereferenced = true
      }
    }
    returnsuper.binaryOperation(insn, v1, v2)
  }

  public override fun ternaryOperation(
    insn: AbstractInsnNode, v1: BasicValue, v2: BasicValue, v3: BasicValue
  ): BasicValue?
  {
    if (v1 is ParamValue)
    {
      when (insn.getOpcode())
      {
        IASTORE,
        LASTORE,
        FASTORE,
        DASTORE,
        AASTORE,
        BASTORE,
        CASTORE,
        SASTORE ->
          dereferenced = true
      }
    }
    returnsuper.ternaryOperation(insn, v1, v2, v3)
  }

  public override fun naryOperation(
    insn: AbstractInsnNode, values: List<BasicValue>
  ): BasicValue?
  {
    if (insn.getOpcode() != INVOKESTATIC)
    {
      dereferenced = values.first() is ParamValue
    }
    if (insn is MethodInsnNode)
    {
      val method = context.findMethodByMethodInsnNode(insn)
      if (method != null && method.isStable())
      {
        for (pos in context.findNotNullParamPositions(method))
        {
          dereferenced =  dereferenced || values[pos.index] is ParamValue
        }
      }
    }
    returnsuper.naryOperation(insn, values);
  }
}

Низкоуровневые детали работы с байткодом уже реализованы в ASM, и интерпретатор работает на весьма высоком уровне абстракции. Все, что он должен делать – порождать значения из уже существующих значений. Работу со слотами и стеком локальных переменных берет на себя библиотека ASM, а все инструкции байткода условно поделены на операции, и интерпретатор выполняет соответствующую инструкцию над соответствующими значениями. Мы просто переопределяем методы, отвечающие за обработку инструкций, при которых могут возникнуть опасные ситуации, и отслеживаем, происходит ли опасная ситуация со значением параметра. Если происходит, выставляем флаг dereferenced в true, и передаем управление исходной реализации переопределенного метода, находящейся в BasicInterpreter. Однако в двух местах вместо передачи управления базовой реализации мы сами порождаем значения. Это происходит в методе unaryOperation – мы специальным образом обрабатываем инструкции (унарные операции) CHECKCAST и INSTANCEOF. Дело в том, что при обработке CHECKCAST BasicInterpreter породит BasicValue, однако нам хочется, чтобы в том случае если выполняется приведение параметра, у нас сохранилась информация о том, что новое значение также является параметром. Поэтому мы явным образом порождаем новое значение типа ParamValue. Также мы хотим запомнить результат выполнения instanceOf над параметром – это делается для порождения значения InstanceOfCheckValue. При обработке вызова метода в naryOperation мы используем информацию из контекста о том, должен ли быть аргумент @NotNull.

Аппроксимация поведения метода и вывод @NotNull-аннотации

Рассмотрим теперь реализацию алгоритма.

Метки, вычисляемые для каждого узла дерева, описываются типом Result, а операция комбинирования этих значений производится методом join:

        enum
        class Result
{
  CYCLE
  {
    override fun join(other: Result) = other
  }
  ERROR
  {
    override fun join(other: Result) = other
  }
  NPE
  {
    override fun join(other: Result) = when (other)
    {
      RETURN -> RETURN
      else -> NPE
    }
  }
  RETURN
  {
    override fun join(other: Result) = RETURN
  }
  abstractfun join(other: Result): Result
}

Представление конфигураций:

        class Configuration(val insnIndex: Int, val frame: Frame<BasicValue>)

Центральный класс реализации – NullParamSpeculator с методом shouldBeNotNull(), который возвращает true, если в случае передачи значения null в рассматриваемый параметр невозможно нормальное завершение метода. methodContext – контекст, в котором собрана вся необходимая для анализа информация.

Метод speculate() вычисляет значение Result.

        class NullParamSpeculator(val methodContext: MethodContext, 
                          val paramIdx: Int)
{
  val method = methodContext.method
  val cfg = methodContext.cfg
  val methodNode = methodContext.methodNode
  val interpreter = ParamSpyInterpreter(methodContext.ctx)

  fun shouldBeNotNull(): Boolean =
    speculate() == Result.NPE

  fun speculate(): Result =
      speculate(
        Configuration(0, createStartFrame(method, methodNode, paramIdx)),
        listOf(),
        false,
        false
      )

  fun speculate(
      conf: Configuration,
      history: List<Configuration>,
      alreadyDereferenced: Boolean,
      nullPath: Boolean
  ): Result
  {
    val insnIndex = conf.insnIndex
    val frame = conf.frame
    if (history.any{
      it.insnIndex == insnIndex && isInstanceOf(frame, it.frame)
    }) return Result.CYCLE
    val cfgNode = cfg.findNode(insnIndex)!!
    val insnNode = methodNode.instructions[insnIndex]
    val (nextFrame, dereferencedHere) = execute(frame, insnNode)
    val nextConfs =
      cfgNode.successors.map{Configuration(it.insnIndex, nextFrame)}
    val nextHistory = history + conf
    val dereferenced = alreadyDereferenced || dereferencedHere
    val opCode = insnNode.getOpcode()
    return when
    {
      opCode.isReturn() && dereferenced ->
        Result.NPE
      opCode.isReturn() ->
        Result.RETURN
      opCode.isThrow() && dereferenced->
        Result.NPE
      opCode.isThrow() && nullPath ->
        Result.NPE
      opCode.isThrow() ->
        Result.ERROR
      opCode == IFNONNULL && Frame(frame).pop() is ParamValue ->
        speculate(nextConfs.first(), nextHistory, dereferenced, true)
      opCode == IFNULL && Frame(frame).pop() is ParamValue ->
        speculate(nextConfs.last(), nextHistory, dereferenced, true)
      opCode == IFEQ && Frame(frame).pop() is InstanceOfCheckValue ->
        speculate(nextConfs.last(), nextHistory, dereferenced, true)
      opCode == IFNE && Frame(frame).pop() is InstanceOfCheckValue ->
        speculate(nextConfs.first(), nextHistory, dereferenced, true)
      else ->
        nextConfs map { conf ->
          speculate(conf, nextHistory, dereferenced, nullPath)
        } reduce { r1, r2 ->
          r1 join r2
        }
    }
  }

  fun execute(
    frame: Frame<BasicValue>, insnNode: AbstractInsnNode
  ): Pair<Frame<BasicValue>, Boolean>
  {
    return when (insnNode.getType())
    {
      AbstractInsnNode.LABEL,
      AbstractInsnNode.LINE,
      AbstractInsnNode.FRAME ->
        Pair(frame, false)
      else -> {
        val nextFrame = Frame(frame)
        interpreter.reset()
        nextFrame.execute(insnNode, interpreter)
        Pair(nextFrame, interpreter.dereferenced)
      }
    }
  }
}

главная логика - в методе speculate со следующей сигнатурой:

fun speculate(
  conf: Configuration,
  history: List<Configuration>,
  alreadyDereferenced: Boolean,
  nullPath: Boolean
): Result

speculate вычисляет результат выполнения для данной конфигурации и истории этой конфигурации.

Параметры метода speculate:

В данной реализации мы не конструируем дерево конфигураций явным образом, нам достаточно лишь иметь историю для каждой конфигурации. Мы собираем результат из подрезультатов рекурсивным спуском. Рассмотрим логику speculate построчно.

Если в истории уже была более общая конфигурация – обнаружен цикл, и результат – CYCLE. Иначе мы выполняем текущую инструкцию (результат выполнения – пара: следующий фрейм и знание о том, был ли параметр разыменован при выполнении текущей инструкции).

Дальше идет разбор возможных вариантов, какая это могла быть инструкция. Если текущая инструкция – возврат, мы подсчитываем Result на основе того, возникала ли опасная ситуация на пути от стартовой конфигурации до данной. Если текущая инструкция – генерация исключения, мы подсчитываем Result на основе того, возникала ли опасная ситуация на пути от стартовой конфигурации до данной, и на основе того, пошли ли мы по ветке, на которой параметр равен null.

Далее идут достаточно интересные моменты. Мы обрабатываем операцию сравнения объекта с null. То есть, обрабатываем ситуацию, представленную в коде на Java фрагментами if (x == null) или if (x != null) – для таких сравнений в байткоде используются инструкции IFNONNULL и IFNULL. Если проверяемый объект – наш параметр, то мы рассматриваем только ту ветку, на которой этот параметр равен null. Действительно, поскольку мы моделируем поведение метода с нулевым параметром, нам не нужно рассматривать ветки, где этот параметр заведомо не равен null. Соответственно, мы рассматриваем только тот путь, где ParamValue – null, и при рекурсивном вызове делаем nullPath = true.

То же самое происходит и при обработке if (x instanceOf SomeClass). Когда x == null, эта проверка всегда вернет false. В java bytecode нет специальной инструкции для ветвления по результатам такой проверки, вначале выполняется проверка, и ее результат (Boolean) сохраняется в стеке, затем выполняется ветвление по инструкции IFEQ или IFNE. Здесь мы применяем ту же логику – если при выполнении этих инструкций на стеке лежит InstanceOfCheckValue, то мы идем только по тому пути, где ParamValue равен null.

На этом заканчивается описание ключевых моментов реализации алгоритма с использованием библиотеки ASM.

Заключение

Все другие детали реализации и примеры аннотирования некоторых популярных библиотек можно посмотреть на сайте проекта https://github.com/ilya-klyuchnikov/kanva-micro.

У рассмотренного метода есть один большой недостаток – он экспоненциальный по сложности. И эта экспонента может взорваться, если в анализируемом методе много ветвлений. Простым способом решения данной проблемы является ограничение количества элементарных шагов (интерпретаций инструкций). Если таких шагов много (допустим, 10000), то мы прекращаем анализ данного метода и просто никак не аннотируем рассматриваемый параметр. Автором разработана модификация алгоритма, которая лишена упомянутого недостатка и пригодна для промышленного использования. Описание модификации содержится в сборнике «Системное программирование», том 8 (http://www.sysprog.info/index.html), который выйдет осенью этого года.

Интересным результатом является то, что простой алгоритм, описанный в данной статье (с разумным ограничением по количеству шагов), на реальных библиотеках вычисляет около 95% аннотаций, выводимых модифицированным алгоритмом за сравнимое время.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 8    Оценка 380        Оценить