Знаете, сколько всего успевает сделать IDE, чтобы показать окно сode сompletion, когда вы начинаете набирать новое слово? Под капотом формулы, полученные с помощью машинного обучения.
Сортировка подсказок — одно из первых применений машинного обучения в IDE JetBrains. Думаем, вам будет интересно узнать, через какие тернии нам пришлось пройти, чтобы эту сортировку реализовать. Данная статья — первая из цикла, в рамках которого мы расскажем, зачем окну с подсказками вообще нужно машинное обучение и как мы решали некоторые связанные с этим задачи:
Сбор датасета
Защита данных
Обучение модели
Оценка качества (включая A/B-тесты для десктопных приложений)
И много чего еще
Вы даже не представляете, сколько всего надо сделать, чтобы показать «простое» окошко типа такого:
Чтобы разобраться, что за машинерия в этом участвует и зачем она нужна, начнем с требований к решению. Как вы считаете, в примере выше показан хороший набор подсказок?
Вы скажете, что все зависит от места вызова окна? Позвольте не согласиться. Эти подсказки плохие, и неважно, где мы их показали. Чтобы понять, почему это так, давайте сначала разберемся, зачем вообще нужны подсказки.
Чего разработчик хочет от code completion?
На этот вопрос есть два ответа. Один — сложный и правильный, другой — простой и тоже правильный. Но одного простого недостаточно.
Простой ответ: меньше печатать
Сравнивая системы подсказок, обычно считают количество нажатий, необходимое для набора одной и той же программы. Пример сценария, в котором эта метрика хороша: разработчики используют длинныеМнемоничныеИдентификаторы
и хотят вводить их быстро и без опечаток. Хорошие подсказки позволяют делать идентификаторы все более длинными и мнемоничными. Программист сам создает длинные имена для сущностей в своем коде и сам же экономит на их наборе с помощью подсказок.
В реальности все заметно сложнее.
Это косвенно подтверждается статьей В. Хеллендоорна и соавторов. В числе прочего они исследовали, в каких ситуациях программисты пользуются подсказками, а в каких — нет (продолжают набор текста или просто закрывают всплывающее окно).
Оказывается, пользователи очень редко выбирают из подсказок ключевые слова языка и названия встроенных типов. Самый частый сценарий использования подсказок — вызов метода (чаще всего API самого проекта), на втором месте — доступ к полям классов. Имена локальных переменных выбирают тоже редко: видимо, они, все же, оказываются недостаточно длинными и мнемоничными.
Наши наблюдения плюс-минус совпадают с выводами авторов статьи, несмотря на то, что они изучали только код на C#, а мы рассмотрели много языков.
В самом деле, кому нужна подсказка, чтобы написать if
? Помните Pascal? Представляете, сколько нажатий можно было бы автоматизировать на integer
и begin
? Даже жалко, что на нем уже почти никто не программирует.
Вывод типов в современных языках программирования работает хорошо, и без явного объявления удается обойтись даже в статически типизируемых языках (пример: var-декларации в Java). В Python с его «утиной» типизацией слова вроде int и float вообще пишешь, только если надо сделать явное приведение. И то каждый раз кажется, что просто плохо программируешь, потому и пришлось их написать.
Ключевые слова языка и встроенные типы подсказывать легко, но у комплишена есть и еще одна задача.
Сложный ответ: снизить когнитивную нагрузку
Какой процент рабочего времени вы пишете код? Готов поспорить, что чтение и отладка кода занимают гораздо больше времени, чем его написание. И, даже когда вы пишете код, основные задержки связаны не с тем, что вы плохо попадаете пальцами по кнопкам. Меньшее количество нажатий на клавиши не гарантирует высокую скорость разработки.
Нам хотелось бы помочь вам быстрее и проще думать, а не только печатать.
Думать разработчику тем быстрее и проще, чем меньше ему приходится помнить. Что именно помнить? В соответствии с упомянутой выше статьей В. Хеллендоорна, более половины выбираемых подсказок — внутрипроектные API. Библиотечные вызовы тоже популярны (как стандартные, так и внешние библиотеки), но внутрипроектное API, все же, используется намного чаще.
Мы считаем, что сделали свою работу хорошо, если вам не пришлось лезть в код коллеги или в документацию (которой зачастую нет), чтобы вспомнить, как называется нужный вам метод — isSetDropAllItemsOnExit()
или shouldDropEverythingWhenLeaving()
? Мы даем быстрый ответ, а вы экономите время и силы.
Также мы вызываем подсказки, когда не помним API. Предположим, вам нужно посмотреть, какие вообще методы есть у объекта. Давайте пофантазируем и разовьем этот сценарий. Представьте, что можно вызвать подсказку в начале новой строки, вообще ничего не набирая. Если мы находимся внутри класса, с абстрактным предком, в меню появятся имена еще не реализованных методов этого предка. И не надо ничего запоминать.
Взглянув еще раз на пример окна с подсказками из начала статьи, мы уже легко поймем, что с ним не так:
Мы видим здесь двухбуквенное ключевое слово, название встроенного типа и три имени из стандартной библиотеки. Все эти подсказки — тривиальные. Скорее всего, ни одна из них не поможет разработчику сэкономить время.
А как сгенерировать такую подсказку, которая поможет?
Требования к окну подсказок
Чтобы предложить разработчику подсказки, которые сэкономят его время, нам нужно выполнить два шага:
Статический анализ — для определения всех доступных символов.
Сортировка списка, полученного в предыдущем пункте.
Последующие статьи этого цикла будут посвящены сортировке. А сейчас давайте обсудим поиск символов-кандидатов. Чего мы от них ждем в первую очередь?
Корректность
Любой разработчик скажет вам, что подсказки прежде всего должны «иметь смысл». Вроде бы это означает, что они должны быть «корректными»: нужно, чтобы они учитывали, что типы в левой и правой частях выражения должны совпадать, ключевые слова должны стоять в правильных местах в соответствии с синтаксисом языка и так далее.
Вы хотите, чтобы ваш код компилировался? Ну а как же! Мы тоже хотим, чтобы ваш код работал. Но оказывается, что требование к корректности сильно переоценено. Когда разработчик начинает писать код, а не рассуждать о нем в теории, приоритеты резко меняются. Вот пара примеров.
Пример 1: приватный метод суперкласса
Допустим, у нас есть такой код:
public class Parent {
private void doSomething() {...}
}
public class Child extends Parent {
public void enhanceSomething() {
doS| ⇐ курсор тут
}
}
Надо ли нам показать doSomething()
в подсказках? Это ведь приватный метод суперкласса, и его не видно с того места, где стоит курсор. Однако показывать его надо, потому как именно этого хочет пользователь. Очень часто разработчику нужно сначала вставить вызов метода, а уж потом отрефакторить суперкласс, сделав этот метод protected
или public
.
А ведь название может быть длинным, вроде doSomethingSpecialAndBeProudAboutIt()
. Пользователь явно разозлится, если мы заставим его набирать это по буквам.
Пример 2: не то ключевое слово
Еще один сценарий, свойственный только Java, — использование ключевых слов extends
и implements
. Наследуя класс, мы пишем extends
, а реализуя интерфейс — implements
.
Но разработчики ведь не всегда помнят, какого типа те сущности, с которыми они имеют дело. Ваш ActionRepeater
— интерфейс или абстрактный класс? Мой SmartActionRepeater
наследует его (extends
) или реализует (implements
)? Не хотелось бы это запоминать и тем более сверяться с документацией. А хотелось бы так: написать все наугад, имя предка выбрать из подсказки, которая (черт побери!) должна его содержать, а ключевое слово, если его не удалось отгадать, поправить уже потом, ориентируясь на красную подсветку.
Бесполезный, но забавный факт: ошибки в использовании extends
и implements
не симметричны. Если разработчик пишет implements
— это признак уверенности. Если же автор кода не помнит, класс там или интерфейс, то, скорее всего, напишет extends
. У нас сохранилось множество старых запросов от пользователей, где они предлагают после extends
показывать в подсказках имена интерфейсов. Аналогичные пожелания по классам и implements
встречается гораздо реже.
В общем, зачастую разработчику значительно проще написать формально некорректный код и затем его быстро поправить, чем писать сразу правильно.
Таким образом, в подсказках должны появляться элементы, которые формально ведут к некорректному коду, но при этом все равно «имеют смысл». Дело в том, что корректные подсказки вычислить нетрудно: у нас есть синтаксическое дерево для каждого файла в проекте, мы умеем обогащать его семантической информацией, и анализ областей видимости в таких условиях относительно прост. А вот «некорректные, но все равно имеющие смысл» варианты найти гораздо сложнее. Для этого надо обработать гораздо больше данных. Например, приходится учитывать все стандартные библиотеки языка, а не только то, что пользователь явно импортировал.
Это приводит к новому классу проблем, и думаю, вы уже догадались, к какому.
Производительность
Окно с подсказками должно появляться быстро. Разработчик не будет ждать его по несколько секунд. Терпение пользователя измеряется миллисекундами. Экспертные оценки максимального приемлемого количества миллисекунд выглядят примерно так:
В идеальном мире мы должны получить список всех разумных вариантов, отсортировать его и отдать пользователю за 150 миллисекунд. В реальности же этого трудно достичь, поскольку для каждого класса подсказок работает свой алгоритм и нам нужно собрать их ответы вместе. Обычно есть отдельные алгоритмы (мы называем их контрибьюторами) для ключевых слов, переменных, имен классов, полей классов и вызовов методов — для разных языков и IDE они могут быть разными. Некоторые из этих контрибьюторов могут иногда тормозить. В худшем случае подсказки не появятся не только за 300 мс (граница «приемлемого)», но и за секунду (хуже, чем «ужасно»).
Чтобы с этим бороться, после того, как отработал первый контрибьютор, мы засекаем еще 100 мс — по истечении этого времени мы показываем окно со всеми полученными к этому моменту результатами. Остальные контрибьюторы тем временем продолжают асинхронно поставлять подсказки.
Что делать с хорошими кандидатами, которые пришли после таймаута? Что делать, если лучший кандидат пришел после таймаута? Мы, конечно, стараемся писать контрибьюторы так, чтобы они в первую очередь возвращали самые релевантные варианты, но очевидно, что так бывает не всегда.
Выбрасывать хорошие результаты жалко, поэтому мы осторожно редактируем окно с подсказками уже после того, как показали его. Это, в свою очередь, может повлиять на удобство использования. Представьте, что вы выделили и приготовились выбрать подсказку, как вдруг она исчезает буквально из-под пальцев и заменяется на другую — релевантную на наш взгляд, но пришедшую с опозданием. Такие подмены могут приводить к ошибкам в коде, если вы их не замечаете, а если замечаете — то здорово раздражать.
Именно поэтому уже около десяти лет мы подменяем элементы только в нижней части окна. Даже если мы уверены, что подсказка хорошая, мы все равно добавим ее на несколько строк ниже текущей выбранной позиции. Верхние позиции «замораживаются» в момент появления окна.
Это компромисс, и у него, конечно, есть свои недостатки. В частности, иногда страдает консистентность: выданный набор подсказок может быть разным в зависимости от загрузки процессора.
Сортировка кандидатов
Допустим, нам повезло: все контрибьюторы отработали вовремя, и у нас на руках есть 820 подсказок. Какие из них показать? Это приводит нас ко второй большой задаче в реализации комплишена — сортировке.
А прямо сейчас
Нам очень интересен ваш опыт использования комплишенов на основе машинного обучения. Интересные ошибки, нестандартные требования — все, с чем вы сталкивались и чем хотели бы поделиться. Мы будем признательны за любую обратную связь.
Уже сейчас подсказки на основе машинного обучения включены по умолчанию для Java, Python, Ruby, JavaScript, TypeScript и Scala. Для других языков пока работают эвристики. Но модную высокотехнологичную версию можно опробовать, поставив соответствующую галочку в настройках:
Не забудьте рассказать нам о своем опыте. А мы будем обрабатывать вашу обратную связь и продолжать улучшать подсказки.