Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
«Почему нельзя просто сделать логично? Я не понимаю».
Из сообщения об ошибке
В первой части мы сформулировали, из каких компонентов состоит система автодополнения, обсудили способы ее использования и требования к качеству. Теперь давайте разберемся, зачем там нужно машинное обучение.
Казалось бы, страшно выбрасывать работающий код и заменять его машиннообученным бинарником, который жрет память, может замедлить работу IDE, да еще не вдруг и отладишь его, если что-то пошло не так.
В нашем случае «работающий код» — это эвристики, жесткие правила. Они отлично работают, пока их не очень много и они не конфликтуют между собой. Давайте рассмотрим такие правила подробнее и разберемся, откуда берутся конфликты.
Совпадение префикса
Это самая важная эвристика. Если пользователь своими руками напечатал какие-то буквы, видимо, они для него важны. Такие буквы и называются префиксом. Давайте уважать выбор пользователя и показывать ему варианты подсказок, содержащие префикс. Такой подход кажется очевидно верным, но даже в нем есть свои подводные камни.
Подсказки поддерживают CamelCase и lowerCamelCase. То есть вы можете печатать только заглавные буквы токена, а в подсказках он покажется целиком — так называемое CamelCase-совпадение. Многие разработчики этим пользуются, но иногда точное совпадение префикса конфликтует с CamelCase-совпадением, например:
static class Benefit {
String getName(){return "Name";}
}
public static void main(String[] args) {
Benefit benefit = new Benefit();
System.out.println(benefit.na| ⇐ caret
}
Что здесь можно предложить, кроме Benefit.getName()
? Оказывается, есть что!
CamelCase-совпадение в начале слова тут взяло верх над точным совпадением в середине, хотя нам понятно, что никто тут notifyAll()
вызывать не хотел. Некоторые считают, что мы должны вообще игнорировать is/get/set и считать «na» точным совпадением в начале слова для getName()
, но мы так далеко заходить не будем. Рассмотрим два других аргумента, почему notifyAll()
не нужно ставить на первое место:
Этот метод ничего не возвращает, а аргументом для
println()
должна быть строка. Если оставить как есть, получится некорректный код.Это метод класса
Object
— он определен очень далеко от того места, которое мы сейчас редактируем. При прочих равных лучше предлагать то, что определено ближе.
Разумеется, для обеих этих причин есть правила, которые их формализуют. Давайте об этом поговорим.
Корректность
Мы уже знаем из первой части, что корректность важна, но не критична. Писать код, который не компилируется, а потом его рефакторить — нормальная практика. Однако здесь другой случай: вряд ли вы собираетесь как-то рефакторить notifyAll()
, переданный в качестве параметра в println()
. К сожалению, мы не смогли написать правила, которые отличают некорректный код от абсолютно некорректного. Давайте перейдем ко второму аргументу.
Логическая близость
Это действительно вполне рабочий параметр для сортировки подсказок.
С подсказкой можно обращаться по-разному в зависимости от того, насколько близко ее определение находится к тому месту, которое сейчас редактирует пользователь:
в том же методе (это локальная переменная)
в том же классе
в том же файле
в том же проекте
в стандартных библиотеках языка
Казалось бы, чем ближе определение к текущему месту, тем лучше. Но всегда есть исключения. Представьте себе, что вы разработчик в JetBrains и пишете редактор для программистов. Вы начинаете печатать «Color
» и видите такую подсказку. Какой из двух вариантов лучше?
Красно-черные деревья (те самые, которые встречаются только на собеседованиях) в редакторе используются, а java.awt — в последние 20 лет библиотека непопулярная. Но java.awt.Color
нам нужен гораздо чаще, и здесь мы хотим получить именно его. Что бы такое еще учесть, чтобы он оказался на первой позиции?
Можно посчитать и как-то учесть, с какой частотой каждый из вариантов подсказок встречается в проекте.
Можно построить транзитивное замыкание (здесь опять нужна шутка про собеседования). Возьмем типы, которые используются в проекте, пройдем по их полям и методам и обогатим предыдущий пункт дополнительной информацией.
Можно посчитать частоту использования каждого имени из стандартных библиотек в известных проектах с открытым кодом, написанных на том же языке.
Можно вспомнить, какие подсказки пользователь недавно выбирал в похожих ситуациях.
Частота — годный фактор. Но если хочется использовать его в эвристиках, придется подбирать пороговые значения. При каком соотношении частот нужно предпочесть символ из внешней библиотеки тому, который определен внутри проекта?
Что касается недавно выбранных подсказок, то у этого фактора есть свой набор проблем.
Недавно выбранные подсказки
Если нам нужно выбрать из двух вариантов подсказок, а вы в недалеком прошлом в похожей ситуации выбрали один из них, то мы будем ставить этот вариант выше, даже если вы потом несколько раз подряд выберете второй. Мы же хотим, чтобы наши подсказки работали стабильно, поэтому порядок будет меняться не слишком часто. С этой стабильностью есть интерфейсная проблема, на которую пользователи время от времени жалуются.
Предположим, мы в первый раз пишем какую-то конструкцию, которую потом собираемся использовать регулярно. И вот в этот первый раз мы промахнулись и случайно выбрали среди подсказок что-то не то. Если сразу не нажать undo, то этот неправильный выбор на некоторое время запомнится и будет настойчиво лезть на высокие позиции.
Кроме того, у разных пользователей разные привычки, и это вносит в сортировку подсказок дополнительный элемент хаоса. Например:
Course course1 = new Course();
Course course2 = new Course();
course1.setName("Test1");
cou| ⇐ caret
Что отобразить выше: course1
или course2
? Одним программистам привычнее продолжить устанавливать атрибуты первого курса. Другие же сначала присвоят имя второму:
course2.setName(“Test2”);
Конечно, этот пример — искусственный, но реальные проблемы на него похожи.
Давайте посмотрим, от чего еще зависит сортировка подсказок.
Длина
Что не так в этом наборе?
Лирическое отступление: разработчики на Golang говорят, что «Impl» — симптом тяжелого заражения джавой. У себя в проектах они именно так выявляют «заболевших» коллег и как-то их лечат, пока те не заполонили весь репозиторий всякими AbstractFactory.
Но даже в Java, где «Impl» выглядит привычно, от «Impl2» ощущения неприятные. Хотя, наверное, у каждого Java-разработчика хоть раз в жизни было искушение так написать.
Но давайте вернемся к теме комплишенов. Нам никогда (ну, почти никогда) не нужно из бизнес-логики вызывать конкретную реализацию. Поэтому в большинстве случаев пользователь выберет название интерфейса или абстрактного класса — их-то и надо вытащить наверх. Этим управляет эвристика, которая называется «lift shorter»: если одно валидное имя является префиксом другого валидного имени, то оно имеет больший приоритет:
Похожая логика применяется и к другим видам токенов, включая имена переменных и названия методов:
Там все не так однозначно, как с именами классов. Но в целом эта логика работает, и нам не нужно писать более сложную. А теперь внезапный поворот сюжета:
@Deprecated
public String suggestVariableName() { … }
Вы когда-нибудь вызывали упраздненные методы? Мы тоже вызывали. Совсем удалять их из подсказок было бы нехорошо, но и ставить на первое место тоже не хочется.
С именами-префиксами есть еще одна неприятность. В примерах выше совпадения имен не случайны, они все явно относятся к одной области. Но может ведь быть и по-другому. Если у вас в проекте заведется переменная nullableSomething, при попытке к ней обратиться на первом месте в подсказках будет null.
А чтобы его там не было, нам нужно добавить еще логики — чтобы разные типы токенов (ключевые слова, константы, переменные, методы, классы и пр.) обрабатывались по-разному.
Уже видите, куда это ведет?
Машинное обучение
В какой-то момент мы поняли, что количество условных операторов в коде сортировки подсказок зашкаливает. Чем дальше, тем сложнее там было что-то поменять. Хуже того, все чаще возникало желание добавить новую галочку в настройки, чтобы пользователь сам решил, насколько ему важен тот или иной фактор. А в настройках, сами знаете, и без нас хватает галочек.
Когда мы осознали, что пишем систему принятия решений со сложной логикой, которую трудно понимать и поддерживать, стало очевидно, что надо эту работу делегировать. Принимать решения, учитывая множество факторов, — как раз та задача, для которой машинное обучения и предназначено.
В следующих статьях цикла мы расскажем, как мы его реализовали и каких результатов добились.