Автоматическая модернизация кода на Go при помощи go fix
- суббота, 21 февраля 2026 г. в 00:00:16
В релизе 1.26 языка Go, выпущенном в этом месяце, есть полностью переписанная подкоманда go fix. Go fix использует набор алгоритмов для обнаружения возможностей улучшения кода; часто для этого применяются более новые фичи языка или библиотеки. В этом посте мы сначала покажем, как использовать go fix для модернизации кодовой базы на Go. Во второй части статьи мы расскажем о лежащей в основе этой подкоманды инфраструктуре и её эволюции. В третьей части мы познакомим вас с тематикой инструментов анализа с «самообслуживанием», которые помогают мейнтейнерам модулей и организациям кодироовать собственные правила и рекомендации.
Команда go fix, как и go build с go vet, принимает множество паттернов, обозначающих пакеты. Эта команда исправляет все пакеты под текущей папкой:
$ go fix ./...
В случае успеха она просто обновляет файлы исходников. Она отклоняет все исправления, касающиеся генерируемых файлов, потому что в этом случае исправление должно вноситься в логику самого генератора. Мы рекомендуем запускать go fix для своего проекта каждый раз, когда вы обновляете сборку до более нового релиза тулчейна Go. Команда может исправлять сотни файлов, поэтому начните с чистого состояния git, чтобы изменения состояли только из исправлений go fix; люди, которые занимаются ревью вашего кода, будут вам благодарны.
Для предварительного просмотра изменений, которые внесёт команда, следует использовать флаг -diff:
$ go fix -diff ./... --- dir/file.go (old) +++ dir/file.go (new) - eq := strings.IndexByte(pair, '=') - result[pair[:eq]] = pair[1+eq:] + before, after, _ := strings.Cut(pair, "=") + result[before] = after …
При помощи этой команды можно получить список доступных анализаторов:
$ go tool fix help … Registered analyzers: any replace interface{} with any buildtag check //go:build and // +build directives fmtappendf replace []byte(fmt.Sprintf) with fmt.Appendf forvar remove redundant re-declaration of loop variables hostport check format of addresses passed to net.Dial inline apply fixes based on 'go:fix inline' comment directives mapsloop replace explicit loops over maps with calls to maps package minmax replace if/else statements with calls to min or max …
При добавлении имени конкретного анализатора отобразится его полная документация:
$ go tool fix help forvar forvar: remove redundant re-declaration of loop variables The forvar analyzer removes unnecessary shadowing of loop variables. Before Go 1.22, it was common to write `for _, x := range s { x := x ... }` to create a fresh variable for each iteration. Go 1.22 changed the semantics of `for` loops, making this pattern redundant. This analyzer removes the unnecessary `x := x` statement. This fix only applies to `range` loops.
По умолчанию команда go fix выполняет все анализаторы. При исправлении большого проекта она может снизить трудозатраты на ревью кода, если вы примените исправления самых активных анализаторов в виде отдельных изменений кода. Чтобы включить только отдельные анализаторы, нужно использовать флаги, соответствующие их именам. Например, чтобы запустить только анализатор any, укажите флаг -any. И наоборот, чтобы запустить все анализаторы, кроме выбранных, обратите флаги, например, -any=false.
Как и в случае с go build и go vet, каждое выполнение команды go fix анализирует только конкретную конфигурацию сборки. Если в вашем проекте активно применяются файлы, помеченные для разных CPU или платформ, то для улучшения покрытия может понадобиться запустить команду несколько раз с разными значениями GOARCH и GOOS:
$ GOOS=linux GOARCH=amd64 go fix ./... $ GOOS=darwin GOARCH=arm64 go fix ./... $ GOOS=windows GOARCH=amd64 go fix ./...
Как мы увидим ниже, многократное выполнение команды также способствует синергическим исправлениям.
Появление дженериков в Go 1.18 ознаменовало конец эпохи внесения незначительных изменений в спецификацию языка и начало периода более быстрого (хоть и по-прежнему аккуратного) развития, особенно в коде библиотек. Многие тривиальные циклы, которые часто пишут программисты на Go, например сбор ключей map в срез, теперь можно удобно выражать как вызов дженерик-функции наподобие maps.Keys. Следовательно, эти новые фичи создают множество новых возможностей по упрощению старого кода.
В декабре 2024 года, в период лихорадочного внедрения LLM-помощников, мы узнали, что эти инструменты создают код Go в стиле, схожем с массой кода на Go, использованного при обучении (и это неудивительно), даже когда появились более новые и совершенные способы выразить ту же самую мысль. Менее очевидно, что эти инструменты часто отказывались использовать новые способы, даже когда им приказывали делать это, например, «всегда используй новейшие идиомы Go 1.25». В некоторых случаях, даже когда разработчик в явном виде приказывал использовать фичу, модель отрицала её существование. (Ещё более раздражающие подробности можно узнать из моего доклада на GopherCon 2025.) Чтобы будущие модели обучались на новых идиомах, нам нужно сделать так, чтобы эти идиомы были отражены в обучающих данных; иначе говоря, в глобальном корпусе опенсорсного кода на Go.
За прошлый год мы создали десятки анализаторов для выявления возможностей модернизации. Вот три примера исправлений, предлагаемых ими:
minmax заменяет оператор if функцией min или max из Go 1.21:
x := f() if x < 0 { x = 0 } if x > 100 { x = 100 }
x := min(max(f(), 0), 100)
rangeint заменяет цикл for с синтаксисом из трёх частей на цикл range для int из Go 1.22:
for i := 0; i < n; i++ { f() }
for range n { f() }
stringscut (вывод -diff которого мы видели выше) заменяет использование strings.Index и срезов на strings.Cut из Go 1.18:
i := strings.Index(s, ":") if i >= 0 { return s[:i] }
before, _, ok := strings.Cut(s, ":") if ok { return before }
Эти модернизаторы включены в gopls для обеспечения мгновенной обратной связи в процессе набора кода, и в go fix, чтобы можно было модернизировать множество пакетов целиком одной командой. Модернизаторы не только делают код чище, но и могут помочь программистам на Go узнавать о новых фичах. В рамках процесса одобрения каждого нового изменения в языке и стандартной библиотеке группа проверки предложений теперь думает и о том, должны ли они сопровождаться модернизатором. Мы ожидаем, что в каждом новом релизе будут добавляться дополнительные модернизаторы.
В Go 1.26 появилось небольшое, но крайне полезное изменение спецификации языка. Встроенная функция new создаёт новую переменную и возвращает её адрес. Раньше её единственным аргументом должен был быть тип, например new(string), а новая переменная инициализировалась своим «нулевым» значением, например "". Начиная с Go 1.26 функцию new можно вызывать с любым значением, благодаря чему она создаёт переменную, инициализированную с этим значением, из-за чего не требуется дополнительная конструкция. Пример:
ptr := new(string) *ptr = "go1.25"
ptr := new("go1.26")
Эта фича ликвидировала пробел, о котором говорили уже больше десятка лет, и привела к внедрению одного из самых популярных предложений по изменениям в языке. Она особенно удобна в коде, использующем тип указателя *T для обозначения опционального значения типа T, как это часто бывает при работе с пакетами сериализации наподобие json.Marshal или protobuf. Это настолько распространённый паттерн, что разработчики часто реализуют его во вспомогательной функции, например в показанной ниже newInt, что позволяет вызывающей стороне не выходить из контекста выражения для добавления дополнительных конструкций:
type RequestJSON struct { URL string Attempts *int // (optional) } data, err := json.Marshal(&RequestJSON{ URL: url, Attempts: newInt(10), }) func newInt(x int) *int { return &x }
Вспомогательные функции наподобие newInt так часто нужны при работе с буферами протоколов, что API proto сам предоставляет их в виде proto.Int64, proto.String и так далее. Но в Go 1.26 все эти вспомогательные функции стали ненужными:
data, err := json.Marshal(&RequestJSON{ URL: url, Attempts: new(10), })
Чтобы помочь вам в применении этой фичи, команда go fix теперь включает в себя исправляющий алгоритм newexpr, распознающий функции, которые похожи на new, например newInt, и предлагающий исправления заменой тела функции на return new(x) и заменой всех вызовов, в том же пакете или в импортируемом пакете, на непосредственное использование new(expr).
Чтобы избежать преждевременного использования новых фич, модернизаторы предлагают исправления только в файлах, требующих как минимум соответствующей версии Go (в данном случае 1.26), или при помощи директивы go 1.26 в файле go.mod, или при помощи build constraint //go:build go1.26 в самом файле.
Эта команда позволяет обновить все вызовы подобного вида в вашем дереве исходников:
$ go fix -newexpr ./...
При удачном стечении обстоятельств все ваши вспомогательные функции наподобие newInt станут неиспользуемыми и их можно безопасно удалить (если они не относятся к стабильному публичному API). Некоторые вызовы могут остаться в тех местах, где исправление предлагать небезопасно, например, когда имя new локально затенено другим объявлением. Также для выявления неиспользуемых функций можно применить команду deadcode.
Применение одной модернизации может создать возможности для применения другой. Например, в этом блоке кода, ограничивающем x интервалом 0–100, модернизатор minmax предложил исправление, использующее max. После применения этого исправления он предложил второе, на этот раз использующее min.
x := f() if x < 0 { x = 0 } if x > 100 { x = 100 }
x := min(max(f(), 0), 100)
Синергии могут возникать и между разными анализаторами. Например, существует распространённая ошибка многократной конкатенации строк в цикле, что приводит к квадратичной временной сложности; это баг и потенциальный вектор атак «отказ в обслуживании». Модернизатор stringsbuilder распознаёт эту проблему и предлагает использовать strings.Builder из Go 1.10:
s := "" for _, b := range bytes { s += fmt.Sprintf("%02x", b) } use(s)
var s strings.Builder for _, b := range bytes { s.WriteString(fmt.Sprintf("%02x", b)) } use(s.String())
После применения этого исправления второй анализатор может понять, что операции WriteString и Sprintf можно объединить в fmt.Fprintf(&s, "%02x", b) — это и чище, и эффективнее; тогда анализатор предложит второе исправление. (Этот второй анализатор —QF1012 из staticcheck Доминика Хоннефа; он уже включен в gopls, но его пока нет в go fix, однако мы планируем добавить анализаторы staticcheck в команду go в Go 1.27.)
Следовательно, стоит попробовать запускать go fix несколько раз, пока изменения не перестанут происходить; обычно достаточно двух раз.
Даже один прогон go fix может внести десятки исправлений в один файл исходников. Все исправления концептуально независимы друг от друга, аналогично множеству коммитов git с одним и тем же родителем. Команда go fix использует простой трёхсторонний алгоритм мерджинга для последовательного совмещения исправлений, аналогично мерджингу множества коммитов git, изменяющих один файл. Если исправление конфликтует с уже накопленным списком изменений, оно отклоняется, а инструмент выдаёт предупреждение о том, что некоторые исправления пропущены и что инструмент нужно запустить заново.
Это надёжным образом позволяет выявлять синтаксические конфликты, возникающие из-за пересекающихся изменений, но возможен и другой класс конфликтов: семантический конфликт возникает, когда два изменения текстово не зависят друг от друга, но их смысл несовместим. Пример: два исправления, каждое из которых удаляет предпоследнее использование локальной переменной: по отдельности каждое исправление приемлемо, но когда они применяются вместе, локальная переменная становится неиспользуемой, а в Go это ошибка компиляции. Ни одно из исправлений не выполняет удаление объявления переменной, но кто-то должен это сделать, и этим кем-то будет пользователь go fix.
Похожий семантический конфликт возникает, когда из-за множества исправлений становится неиспользуемым импорт. Поскольку этот случай сильно распространён, команда go fix применяет финальный проход для обнаружения неиспользуемых импортов и их автоматического удаления.
Семантические конфликты относительно редки. К счастью, они обычно проявляются в виде ошибок компиляции, благодаря чему их невозможно не заметить. К сожалению, когда они случаются, то требуют ручного вмешательства после выполнения go fix.
Теперь давайте изучим инфраструктуру, лежащую в основе этих инструментов.
С самых ранних этапов развития Go у команды go существовали две подкоманды для статического анализа: go vet и go fix; каждая из них имела собственный набор алгоритмов, разделённых на «проверяющие» и «исправляющие». Проверяющие сообщали о вероятных ошибках в коде, например о передаче строки вместо integer в качестве операнда преобразования fmt.Printf("%d"). Исправляющие безопасно редактировали код, устраняя баги или выражая смысл кода более качественно (чётче, компактнее или эффективнее). Иногда один и тот же алгоритм использовался в обоих наборах, если он мог сообщить об ошибке и безопасно её устранить.
В 2017 году мы перепроектировали монолитную программу go vet, отделив проверяющие алгоритмы (теперь называющиеся «анализаторами») от «драйвера», то есть от запускающей их программы; в результате возник фреймворк анализа Go. Благодаря этому разделению можно было написать анализатор один раз, после чего запускать его в широком спектре драйверов для разных окружений, например:
unitchecker — это драйвер, превращающий набор анализаторов в подкоманду, которую можно выполнять масштабируемой системой инкрементной сборки команды go, аналогично компилятору в go build. Это фундамент go fix и go vet.
nogo — аналогичный драйвер для альтернативных систем сборки наподобие Bazel и Blaze.
singlechecker превращает анализатор в автономную команду, которая загружает и парсит множество пакетов (или целую программу), выполняет проверки типов, а затем анализирует пакеты. Мы часто используем его для экспериментов и метрик с корпусом зеркала модулей (proxy.golang.org).
multichecker делает то же самое для набора анализаторов при помощи универсального CLI.
gopls — языковой сервер, используемый VS Code и другими редакторами; в реальном времени предоставляет диагностику анализаторов после каждого нажатия клавиши в редакторе.
драйвер с большими возможностями конфигурирования, используемый инструментом staticcheck. (Staticcheck также предоставляет большой набор анализаторов, которые могут выполняться в других драйверах.)
Tricorder — конвейер пакетного статического анализа, используемый монорепозиторием Google и интегрированный с системой ревью кода компании.
MCP-сервер gopls предоставляет доступ к диагностике кодинг-агентам на основе LLM, обеспечивая более надёжную защиту.
analysistest — тестовая обвязка фреймворка анализа.
Одно из преимуществ фреймворка заключается в возможности выражения вспомогательных анализаторов, не сообщающих диагностику и не предлагающих исправления самостоятельно, а вычисляющих промежуточную структуру данных, которая была бы полезна множеству других анализаторов, амортизируя затраты на его создание. Например, это могут быть графы потока управления, SSA-представление тел функций и структуры данных для оптимизированной навигации по AST.
Ещё одно преимущество фреймворка состоит в поддержке создания выводов между пакетами. Анализатор может прикрепить к функции «факт» или иной символ, чтобы информацию, полученную при анализе тела функции, можно было использовать при дальнейшем анализе вызова функции, даже если вызов встречается в другом пакете или позже выполняется анализ в другом процессе. Это позволяет с лёгкостью создавать масштабируемые межпроцедурные аналитические структуры. Например, проверяющий printf алгоритм может сообщать, когда функция наподобие log.Printf на самом деле оказывается просто обёрткой вокруг fmt.Printf, чтобы он знал, что вызовы log.Printf должны проверяться аналогичным образом. Этот процесс выполняется индуктивно, поэтому инструмент также проверяет вызовы обёрток вокруг log.Printf и так далее. Примером анализатора, активно использующего факты, можно считать nilaway Uber, сообщающий о потенциальных ошибках, приводящих к разыменованию пустых указателей.

Процесс «отдельного анализа» в go fix аналогичен процессу раздельной компиляции в go build. Точно так же, как компилятор собирает пакеты, начиная с низа графа зависимостей и передавая информацию вверх импортированным пакетам, фреймворк анализа работает снизу графа зависимостей, передавая факты (и типы) импортированным пакетам.
В 2019 году, когда мы начали разрабатывать языковой сервер Go gopls, нами была добавлена возможность анализатора предлагать исправление при диагностическом отчёте. Например, анализатор printf предлагает заменять fmt.Printf(msg) на fmt.Printf("%s", msg), чтобы избежать ошибочного форматирования в случае, когда динамическое значение msg содержит символ %. Этот механизм стал основой множества быстрых исправлений и фич рефакторинга gopls.
Пока все эти разработки велись с go vet, go fix оставалась такой же, какой была до объявления о совместимости Go, когда первые пользователи Go использовали её во время быстрой и иногда не обеспечивающей эволюции языка и библиотек.
Релиз Go 1.26 привнёс в go fix фреймворк анализа Go. Команды go vet и go fix были объединены, и теперь их реализация практически идентична. Единственные различия между ними заключаются в критериях используемых ими наборов алгоритмов и в действиях с вычисленной диагностикой. Анализаторы vet должны обнаруживать вероятные ошибки с низкой частотой ложноположительных срабатываний; их диагностика передаётся пользователю. Анализаторы fix должны генерировать исправления, которые безопасно можно применять без регрессии в корректности, производительности и стиле; их диагностика может не передаваться, но исправления применяются напрямую. Если не считать этой разницы, задача разработки исправляющего анализатора не отличается от разработки проверяющего.
В процессе увеличения количества анализаторов go vet и go fix мы прилагали усилия к развитию инфраструктуры и для повышения производительности каждого анализатора, и для упрощения написания новых анализаторов.
Например, большинство анализаторов начинают свою работу с обхода синтаксических деревьев каждого файла в пакете в поисках определённого типа узла, например, конструкции range или литерала функции. Готовый пакет inspector повышает эффективность этого сканирования, выполняя предварительное вычисление компактного индекса полного обхода, чтобы последующие обходы могли быстро пропускать поддеревья, не содержащие нужных узлов. Недавно мы расширили наш тип данных Cursor, обеспечив гибкую и эффективную навигацию по узлам во всех четырёх направлениях (вверх, вниз, влево и вправо), аналогично навигации по элементам HTML DOM, благодаря чему теперь можно легко и эффективно выражать запросы наподобие «найти каждую конструкцию go, являющуюся первой конструкцией тела цикла»:
var curFile inspector.Cursor = ... // Находим каждую конструкцию go, являющуюся первой конструкцией тела цикла. for curGo := range curFile.Preorder((*ast.GoStmt)(nil)) { kind, index := curGo.ParentEdge() if kind == edge.BlockStmt_List && index == 0 { switch curGo.Parent().ParentEdgeKind() { case edge.ForStmt_Body, edge.RangeStmt_Body: ... } } }
Многие анализаторы начинают работу с поиска вызовов определённой функции, например fmt.Printf. Вызовы функций — одни из самых частых выражений в коде на Go, поэтому вместо того, чтобы искать каждое выражение вызова и проверять, является ли оно вызовом fmt.Printf, гораздо эффективнее предварительно вычислить индекс символьных ссылок, что можно сделать при помощи typeindex и его вспомогательного анализатора. Тогда можно напрямую создать список вызовов fmt.Printf, благодаря чему затраты становятся пропорциональны количеству вызовов, а не размеру пакета. В случае анализаторов наподобие hostport, который ищет редко используемый символ (net.Dial), ускорение может запросто быть тысячекратным.
Перечислю также и другие инфраструктурные улучшения, реализованные за прошлый год:
граф зависимостей стандартной библиотеки, с которым могут сверяться анализаторы, чтобы не добавлять петли импорта. Например, мы не можем добавить вызов strings.Cut в пакет, который сам импортируется strings.
поддержка запросов фактической версии Go файла, определяемой файлом go.mod и тэгами сборки, чтобы анализаторы не вставляли фичи, которые будут «слишком новыми».
более богатая библиотека примитивов рефакторинга (например, «удалить эту конструкцию»), которая корректно обрабатывает соседние комментарии и другие сложные пограничные случаи.
Мы прошли долгий путь, но нам предстоит ещё многое сделать. Иногда реализация логики исправляющих анализаторов может быть сложной. Так как мы ожидаем, что пользователи будут применять сотни предложенных исправлений, проверив их лишь поверхностно, очень важно, чтобы исправления были корректными даже в редких пограничных случаях. Вот один из примеров (множество других можно посмотреть в моём докладе на GopherCon): мы написали модернизатор, заменяющий вызовы вида append([]string{}, slice...) на более чистые slices.Clone(slice), но обнаружили, что когда slice пуст, результат Clone равен nil; это малозаметное изменение поведения может в редких случаях вызывать баги, поэтому мы были вынуждены исключить этот модернизатор из набора go fix.
Часть затруднений авторов анализаторов можно решить улучшением документации (и для людей, и для LLM); в частности, составлением чеклистов неожиданных пограничных случаев, которые нужно учитывать и тестировать. Движок сопоставления паттернов для синтаксических деревьев, схожий с используемым в staticcheck и Tree Sitter, может упростить запутанный процесс эффективного нахождения мест, требующих исправления. Расширение библиотеки операторов для вычисления точных исправлений поможет избегать распространённых ошибок. Улучшенная тестовая обвязка позволит проверять, что исправления не ломают сборку, и сохранять динамические свойства целевого кода. Все эти задачи уже есть в нашем плане работ.
В более фундаментальном отношении в 2026 году мы переходим к парадигме «самообслуживания».
Описанный выше анализатор newexpr — это типичный модернизатор: алгоритм, написанный под конкретную уникальную фичу. Модель уникальности хорошо подходит для фич языка и стандартной библиотеки, но не особо помогает модернизировать использование сторонних пакетов. Конечно, ничто не мешает вам написать модернизатор для собственного публичного API и запускать его для своего проекта, но пользователи вашего API никак не смогут автоматически запускать его у себя. Если ваш API не используется широко в экосистеме Go, то, скорее всего, ваш модернизатор не связан с gopls или go vet. Но даже если он с ними связан, вам придётся получать ревью кода и одобрение, после чего ждать следующего релиза.
В парадигме самообслуживания программисты на Go смогут определять модернизации собственных API, которые пользователи смогут применять без всех узких мест, связанных с нынешней централизованной парадигмой. Это особенно важно потому, что сообщество Go и глобальный корпус Go растут гораздо быстрее, чем увеличиваются возможности нашей команды по проверке анализаторов, разработанных контрибьюторами.
Команда go fix в Go 1.26 включает в себя возможность оценить первые плоды этой новой парадигмы: инлайнер на уровне исходного кода, управляемый аннотациями (annotation-driven source-level inliner), который мы опишем в одном из следующих постов. В этом году мы планируем исследовать ещё два подхода в рамках этой парадигмы.
Во-первых, мы изучим возможность динамической загрузки модернизаторов из дерева исходников и их безопасного выполнения или в gopls, или в go fix. При таком подходе пакет, предоставляющий API, допустим, для базы данных SQL, может дополнительно предоставлять анализатор, проверяющий случаи неправильного использования API, например уязвимости к SQL-инъекциям или отсутствие обработки критических ошибок. Тот же самый механизм могут использовать мейнтейнеры проекта для кодирования внутренних правил, например запрета вызова определённых проблемных функций или принудительного использования более строгих условий кодинга на критических путях исполнения кода.
Во-вторых, многие готовые проверяющие анализаторы можно неформально описать как «не забудь сделать А после того, как сделал Б», например «закрой файл после его открытия», «отмени контекст после его создания», «разблокируй мьютекс после его блокировки», «выйди из цикла итератора после того, как yield вернёт false» и так далее. Общее для всех таких проверяющих анализаторов то, что они навязывают определённые инварианты на всех путях исполнения кода. Мы планируем изучить обобщения и унификации таких проверок потока управления, чтобы программисты на Go с лёгкостью могли бы применять их к новым предметным областям без сложной аналитической логики, простой аннотацией их собственного кода.
Мы надеемся, что эти новые инструменты сэкономят вам время и силы в процессе поддержки проектов на Go, помогут вам быстрее обучиться новым фичам и извлечь из них пользу. Попробуйте go fix в своих проектах и сообщайте о всех найденных проблемах, а также делитесь своими идеями новых модернизаторов, исправляющих и проверяющих анализаторов, концепций самообслуживания в статическом анализе.