Генерация кода

5 minute read

Перевод статьи от Роба Пайка про генерацию кода. Из официального блога.

Свойство универсальных вычислений - Тьюринг полнота - это способность одной программы написать другую программу. Это мощная идея, которая, как правило, остается недооцененной. Например, это одна из основных частей, определяющих компилятор. Так же, по этому принципу работает команда для тестирования: сначала сканируются пакеты, которые должны быть протестированы, создается временная программа содержащая обвязку для тестов и кастомизированная для конкретного пакета, затем эта временная программа компилируется и запускается. Хотя это довольно большая последовательность действий, современные компьютеры достаточно быстры, чтобы выполнить ее за миллисекунды.

Есть много других примеров, когда программы пишут программы. Yacc, к примеру, читает описание грамматики и генерирует программу, которая может разбирать эту грамматику. "Компилятор" протобафа(Protocol Buffers) читает описание интерфейсов и генерирует определения структур, методов и другой сопутствующий код. Различные средства конфигурации работают аналогичным способом, получая информацию о окружении и генерируя различные скрипты, кастомизированные под конкретное окружение.

Программы которые пишут другие программы - это важный элемент в разработке программного обеспечения. Но программы типа Yacc генерируют код, который должен быть интегрирован в процессе сборки, то есть, результат может быть скомпилирован. Когда используются сторонние инструменты сборки, такие как Make, то это становится простой задачей. Но в Go инструменты сборки получают всю информацию только из исходников .go и это становится проблемой. Просто нет механизма для запуска с Yacc помощью go инструментов.

Но теперь такая возможность появилась.

Последний релиз Go 1.4 включает новую команду, которая упрощает запуск таких тулз. Она называется go generate. Работает сканируя определенные комментарии в исходниках go кода, которые определяют какие средства генерации нужно запустить. Важный момент для понимания: go generate не является частью go build. Эта команда не анализирует зависимости и должна запускаться до go build. Кроме того, генерация должна использоваться только автором пакета, но не его пользователями.

Использовать go generate довольно просто. Для разминки, посмотрим как генерировать Yacc грамматики. Предположим, у вас есть входной файл gopher.y который описывает грамматики для вашего нового языка. Для получения исходника .go реализующего эту грамматику, нам, как правило, нужно вызвать стандартную версию yacc для Go:

go tool yacc -o gopher.go -p parser gopher.y

Параметр -o определяет имя выходного файла, -p указывает пакет.

Чтобы воспользоваться командой go generate в любом не генерируемом .go файле укажите специальный комментарий в любом месте файла:

//go:generate go tool yacc -o gopher.go -p parser gopher.y

Этот текст просто команда, описанная выше, со специальным префиксом комментария который распознается с помощью go generate. Этот комментарий должен стоять в начале строки и не должно быть пробелов между // и go:generate. После этого маркера идет команда которая будет выполнятся в момент запуска go generate.

Теперь запустим все это. Заходим в директорию с проектом и выполняем go generate затем go build и так далее:

$ cd $GOPATH/myrepo/gopher
$ go generate
$ go build
$ go test

Вот так это работает. Предположим, что у нас не было никаких ошибок, команда go generate запустила yacc который создаст gopher.go. После этого у нас будут все необходимые файлы для сборки проекта, тестирования и нормальной работы. После каждой модификации gopher.y необходимо просто запустить go generate для перегенерирования парсера.

Смотрите документацию для более подробного понимания работы go generate, включая параметры, переменные окружения и все остальное.

Эта команда не делает ничего, что нельзя сделать с помощью Make или других инструментов сборки, но теперь это идет в коробке и прекрасно вписывается в Go экосистему. Только не забывайте, это инструмент для разработчиков пакетов но не для их пользователей. Кроме того, если пакет должен быть получен с помощью go get, как только файл будет сгенерирован и оттестирован он должен быть добавлен в систему контроля версий и быть доступным для клиентов.

Теперь попробуем что то новенькое. Совершенно другой пример выгоды от go generate - возможность использовать программу stringer доступную как часть golang.org/x/tools. Эта программа автоматически генерирует строковые методы для наборов целочисленных констант. Она не является частью стандартной поставки, но ее легко установить дополнительно:

$ go get golang.org/x/tools/cmd/stringer

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

package painkiller

type Pill int

const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
    Paracetamol
    Acetaminophen = Paracetamol
)

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

func (p Pill) String() string

Который реализуется вот так:

func (p Pill) String() string {
    switch p {
    case Placebo:
        return "Placebo"
    case Aspirin:
        return "Aspirin"
    case Ibuprofen:
        return "Ibuprofen"
    case Paracetamol: // == Acetaminophen
        return "Paracetamol"
    }
    return fmt.Sprintf("Pill(%d)", p)
}

Конечно, есть и другие способы, чтобы написать эту функцию. Мы можем использовать слайс со строками и Pill в качестве индексов, или мапы, или другие способы. Но в любом случае, мы должны следить за списком таблеток и за правильностью этой функции (два названия парацетамола сделают эту задачу еще сложнее). Кроме того, сам вопрос какой подход использовать зависит от типов и значений: знаковые и беззнаковые, с нуля или нет и так далее.

Программа stringer заботиться о всех деталях. Хотя она может быть запущена отдельно, но также ее можно использовать совместно с go generate. Чтобы заюзать это, добавьте специальный комментарий рядом с определением типа:

//go:generate stringer -type=Pill

Это правило означает, что должна запуститься программа stringer, которая сгенерирует String метод для типа Pill. Результат работы программы запишется в файл pill_string.go (это название по умолчанию, его можно изменить с помощью флага -o).

Давайте запустим это:


$ go generate

Содержимое файла pill_string.go:

// generated by stringer -type Pill pill.go; DO NOT EDIT

package pill

import "fmt"

const _Pill_name = "PlaceboAspirinIbuprofenParacetamol"

var _Pill_index = [...]uint8{0, 7, 14, 23, 34}

func (i Pill) String() string {
    if i < 0 || i+1 >= Pill(len(_Pill_index)) {
        return fmt.Sprintf("Pill(%d)", i)
    }
    return _Pill_name[_Pill_index[i]:_Pill_index[i+1]]
}

Теперь при изменении типа Pill, нам просто нужно будет запустить

$ go generate

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

Без сомнения, сгенерированный метод уродский. Это нормально, потому что люди не будут работать в рамках этого метода, а машино-сгенерированный код всегда уродский. Все имена объединены вместе в одну строку, которая сохраняется в памяти(только одно строка для всех имен, даже если имен будет 100500 миллионов). Дальше идет массив _Pill_index с индексами для имен, реализующий простую технологию, аналогичную map. Обратите внимание, что _Pill_index именно массив(а не слайс) с элементами типа uint8 - самый легкий интовый тип достаточный для охвата всего диапазона значений. Если у нас будет больше значений или появятся отрицательные, тогда, при генерации, тип автоматически замениться на более подходящий.

Подход, реализуемый в строковом методе который генерируется с помощью stringer, так же может меняться в зависимости от списка констант. К примеру, если константы не большие, можно использовать map. Вот тривиальный пример для констант степеней двойки:

const _Power_name = "p0p1p2p3p4p5..."

var _Power_map = map[Power]string{
    1:    _Power_name[0:2],
    2:    _Power_name[2:4],
    4:    _Power_name[4:6],
    8:    _Power_name[6:8],
    16:   _Power_name[8:10],
    32:   _Power_name[10:12],
    ...,
}

func (i Power) String() string {
    if str, ok := _Power_map[i]; ok {
        return str
    }
    return fmt.Sprintf("Power(%d)", i)
}

Проще говоря, автоматическая генерация делает работу быстрей и эффективней чем живой человек.

Есть множество других способов использования go generate в Go. Это создание юникод-таблиц в пакете unicode, создание эффективных методов для кодирования и декодирования массивов в пакете encoding/gob, получение данных для временных зон в пакете time и многое другое.

Пожалуйста, используйте go generate творчески и экспериментируйте.

И обязательно используйте инструмент stringer для машинной генерации более эффективного кода.