В этом сообщении блога описываются те же приемы, которые использовались во время 25-го эпизода justforfunc, который вы можете посмотреть прямо ниже.

Ранее в justforfunc

В предыдущем посте мы использовали пакет go/scanner в стандартной библиотеке Go, чтобы определить, какой идентификатор является наиболее распространенным идентификатором в самой стандартной библиотеке.

Спойлер: это было v.

Чтобы получить более значимый список, мы ограничили поиск теми идентификаторами, которые состоят из трех или более букв. Это дало нам err и nil, которые мы все уже видели раньше в (не) известном выражении if err != nil {.

Переменные: package vs local

Что, если бы я хотел узнать, какое имя является наиболее распространенным для локальных переменных? А как насчет функций или типов? Сканеры не могут ответить на этот вопрос, потому что им не хватает контекста. Мы знаем, какие токены мы видели раньше, но чтобы узнать, объявлен ли var a = 3 на уровне пакета, функции или даже блока, нам нужно больше контекста.

Пакет имеет много объявлений, некоторые из этих объявлений могут относиться к функциям, которые со временем могут объявлять локальные переменные, константы или даже больше функций!

Но как нам найти эту структуру из последовательности токенов? Что ж, у каждого языка программирования есть набор правил, которые информируют нас о том, как построить такое дерево из последовательности токенов. Это выглядит примерно так:

VarDecl = "var" ( VarSpec | "(" { VarSpec ";" } ")" ) .
VarSpec = IdentifierList ( Type [ "=" ExpressionList ] | "="  
                        ExpressionList ) .

Приведенные выше правила говорят нам, что VarDecl (объявление переменной) начинается с токена var, за которым следует либо VarSpec (спецификация переменной), либо их список, заключенный в круглые скобки и разделенный точками с запятой.

Примечание. Эти точки с запятой фактически добавляются сканером Go, поэтому вы можете их не видеть, но анализатор видит.

Если мы начнем с фрагмента кода Go, содержащего var a = 3, используя go/scanner, мы сможем получить следующий список токенов.

[VAR], [IDENT "a"], [ASSIGN], [INT "3"], [SEMICOLON]

Приведенные выше правила помогают нам понять, что это VarDecl только с одним VarSpec. Затем мы проанализируем IdentifierList с одним Identifier a, без Type и ExpressionList с Expression, которое является целым числом со значением 3.

Или, как дерево, это будет выглядеть, как на изображении ниже.

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

Использование go / scanner

А пока хватит теории, давайте напишем код! Давайте посмотрим, как мы можем проанализировать выражение var a = 3 и получить из него AST.

Эта программа компилируется (ура!), Но если вы запустите, вы увидите следующую ошибку:

1:1: expected 'package', found 'var' (and 1 more errors)

Ах, да. Чтобы проанализировать объявление, мы вызываем ParseFile, который ожидает полный файл Go, поэтому начинается с package перед любым другим кодом (кроме комментариев).

Если вы разбирали выражение, такое как 3 + 5, или другие фрагменты кода, которые вы могли видеть в качестве значения, которое можно было бы передать в качестве параметра, тогда у вас есть ParseExpr, но эта функция не поможет с объявлением функции.

Давайте просто добавим package main в начало нашего кода и посмотрим, как мы получим AST.

И когда мы запускаем его, мы получаем что-то немного лучше ... хотя и немного.

$ go run main.go
&{<nil> 1 main [0xc420054100] scope 0xc42000e210 {
        var a
}
 [] [] []}

Давайте напечатаем более подробную информацию, заменив вызов Println на fmt.Printf("%#v", f) и попробуем еще раз.

go run main.go
&ast.File{Doc:(*ast.CommentGroup)(nil), Package:1, Name:(*ast.Ident)(0xc42000a060), Decls:[]ast.Decl{(*ast.GenDecl)(0xc420054100)}, Scope:(*ast.Scope)(0xc42000e210), Imports:[]*ast.ImportSpec(nil), Unresolved:[]*ast.Ident(nil), Comments:[]*ast.CommentGroup(nil)}

Хорошо, так лучше, но мне лень это читать. Давайте импортируем github.com/davecgh/go-spew/spew и воспользуемся им для печати более удобного для чтения значения.

Запуск этой программы показывает нам почти то же самое, что и раньше, но в гораздо более читаемом формате.

$ go run main.go
(*ast.File)(0xc42009c000)({
 Doc: (*ast.CommentGroup)(<nil>),
 Package: (token.Pos) 1,
 Name: (*ast.Ident)(0xc42000a120)(main),
 Decls: ([]ast.Decl) (len=1 cap=1) {
  (*ast.GenDecl)(0xc420054100)({
   Doc: (*ast.CommentGroup)(<nil>),
   TokPos: (token.Pos) 15,
   Tok: (token.Token) var,
   Lparen: (token.Pos) 0,
   Specs: ([]ast.Spec) (len=1 cap=1) {
    (*ast.ValueSpec)(0xc4200802d0)({
     Doc: (*ast.CommentGroup)(<nil>),
     Names: ([]*ast.Ident) (len=1 cap=1) {
      (*ast.Ident)(0xc42000a140)(a)
     },
     Type: (ast.Expr) <nil>,
     Values: ([]ast.Expr) (len=1 cap=1) {
      (*ast.BasicLit)(0xc42000a160)({
       ValuePos: (token.Pos) 23,
       Kind: (token.Token) INT,
       Value: (string) (len=1) "3"
      })
     },
     Comment: (*ast.CommentGroup)(<nil>)
    })
   },
   Rparen: (token.Pos) 0
  })
 },
 Scope: (*ast.Scope)(0xc42000e2b0)(scope 0xc42000e2b0 {
        var a
}
),
 Imports: ([]*ast.ImportSpec) <nil>,
 Unresolved: ([]*ast.Ident) <nil>,
 Comments: ([]*ast.CommentGroup) <nil>
})

Я рекомендую потратить некоторое время на то, чтобы прочитать это дерево и посмотреть, как каждая часть соответствует исходному коду. Не обращайте внимания на Scope, Obj и Unresolved, поскольку мы поговорим о них позже.

Переход от AST к коду

Иногда бывает полезно распечатать AST в соответствующем исходном коде, а не в его древовидной форме. Для этого у нас есть пакет go/printer, который вы можете легко использовать как способ узнать, какая информация хранится в AST.

При выполнении этой программы печатается исходный код, который мы проанализировали изначально. Теперь самое время увидеть, как различные значения режима синтаксического анализа влияют на то, какая информация хранится в AST. Замените parser.AllErrors на parser.ImportsOnly или другие возможные значения.

Навигация по AST

На данный момент у нас есть дерево, которое содержит всю необходимую информацию, но как нам извлечь те части информации, которые нам небезразличны? Вот где пригодится пакет go/ast (кроме объявления ast.File, возвращаемого parser.ParseFile!).

Давайте использовать ast.Walk. Эта функция получает два аргумента. Второй - это ast.Node, который является интерфейсом, поддерживаемым всеми узлами в AST. Первый аргумент - это ast.Visitor, который также, очевидно, является интерфейсом.

Этот интерфейс имеет единственный метод.

Итак, у нас уже есть узел, поскольку ast.File, возвращаемый parser.ParseFile, удовлетворяет интерфейсу, но нам все еще нужно создать ast.Visitor.

Давайте просто напишем такой, который печатает тип узла и возвращает себя.

Запуск этой программы дает нам последовательность узлов, но мы потеряли древовидную структуру. Кроме того, что это за nil узлы? Что ж, мы должны прочитать документацию на ast.Walk! Оказывается, Visitor, который мы возвращаем, используется для посещения каждого из дочерних узлов текущего узла, и мы заканчиваем тем, что вызываем Visitor с узлом nil, чтобы сообщить, что больше нет узлов для посещения.

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

Остальная часть кода в нашей программе остается неизменной, и ее выполнение выводит следующий результат:

*ast.File
        *ast.Ident
        *ast.GenDecl
                *ast.ValueSpec
                        *ast.Ident
                        *ast.BasicLit

Какое наиболее распространенное имя для каждого типа идентификатора?

Итак, теперь, когда мы понимаем, как анализировать код и посещать его узлы, мы готовы извлечь нужную информацию: каковы наиболее распространенные имена для переменных, объявленных на уровне пакета, по сравнению с именами, объявленными локально внутри функции.

Мы начнем с фрагмента кода, очень похожего на тот, который был у нас в предыдущем эпизоде, где мы использовали go/scanner над списком файлов, переданных в качестве аргументов командной строки.

Запуск этой программы распечатает AST каждого из файлов, заданных в качестве аргумента командной строки. Вы можете попробовать это самостоятельно, запустив:

$ go build -o parser main.go  && parser main.go
# output removed for brevity

Теперь давайте изменим наш visitor, чтобы отслеживать, сколько раз каждый идентификатор используется для каждого типа объявления переменных.

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

Для каждого оператора присваивания мы проверяем, является ли имя идентификатора _, которое следует игнорировать, и является ли это точкой объявления идентификатора. Для этого мы используем поле Obj, которое отслеживает все объекты, объявленные в контексте.

Если поле Obj равно nil, мы знаем, что переменная была объявлена ​​в другом файле, поэтому это не объявление локальной переменной, и мы можем ее игнорировать.

Если мы запустим эту программу для всей стандартной библиотеки, мы увидим, что наиболее распространенными идентификаторами являются:

  7761 err
  6310 x
  5446 got
  4702 i
  3821 c

Интересно, что v вообще не появляется! Нам не хватает каких-либо других способов объявления локальных переменных?

Подсчет параметров и переменных диапазона

Нам не хватает пары типов узлов для более полного анализа локальных переменных. Эти:

  • параметры функции, получатели и именованные возвращаемые значения.
  • операторы диапазона.

Поскольку код для подсчета идентификатора локальной переменной будет повторяться повсюду, давайте вместо этого определим метод для visitor.

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

Затем мы можем использовать этот метод для всех типов узлов, которые могут объявлять локальные переменные.

Отлично, давайте запустим это и посмотрим, какое имя локальной переменной сейчас является наиболее распространенным!

$ ./parser ~/go/src/**/*.go
most common local variable names
 12264 err
  9395 t
  9163 x
  7442 i
  6127 c

Обработка объявлений var

Перейдем к обработке объявлений var. Они более интересны, потому что они могут быть локальными или глобальными, и единственный способ узнать, находятся ли они на уровне ast.File.

Для этого мы собираемся создать новый visitor для каждого файла, который будет отслеживать глобальные объявления, чтобы мы могли правильно подсчитывать идентификаторы.

Для этого мы добавим поле pkgDecls типа map[*ast.GenDecl]bool в нашего посетителя, и оно будет инициализировано функцией newVisitor. Мы также добавим globals поле, отслеживающее, сколько раз был объявлен идентификатор.

Наша основная программа должна будет создать новое visitor для каждого файла и отслеживать общие результаты.

Хорошо, нам осталось завершить еще один кусок головоломки. Нам нужно отследить *ast.GenDecl узлов и найти в них все объявления переменных.

Для каждого объявления мы будем считать только те, которые начинаются с token.VAR, игнорируя константы, типы и другие идентификаторы. Затем для каждого объявленного значения мы проверим, является ли оно глобальным или локальным, и посчитаем их соответственно, игнорируя _.

Вся программа доступна здесь.

Запуск программы даст нам такой результат:

$ ./parser ~/go/src/**/*.go
most common local variable names
 12565 err
  9876 x
  9464 t
  7554 i
  6226 b
most common global variable names
    29 errors
    28 signals
    23 failed
    15 tests
    12 debug

Итак, самое распространенное имя локальной переменной - err, а наиболее распространенное имя переменной пакета - errors.

Какое из имен констант является наиболее распространенным? Как бы вы его нашли?

Спасибо

Если вам понравился этот эпизод, обязательно поделитесь им, подпишитесь на justforfunc и подпишитесь на меня в Medium или twitter! Также рассмотрите возможность спонсирования сериала на patreon.