Хабрахабр

Контроль консистентности кода в Go

Если вы считаете консистентность важной составляющей качественного кода — эта статья для вас.

Вас ждут:

  • Разные способы сделать одно и то же в Go (эквивалентные операции)
  • Менее очевидные факторы, влияющие на однородность вашего кода
  • Способы увеличения консистентности вашего проекта

Для начала определимся с тем, что мы называем "консистентностью".

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

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

В этой статье я иногда буду употреблять контекстные синонимы во избежание частой тавтологии. Иногда вместо слова "консистентность" используется "согласованность".

Существуют разные уровни консистентности, например мы можем выделить три наиболее очевидные:

  • Согласованность на уровне одного файла исходного кода
  • Согласованность на уровне пакета (или библиотеки)
  • Согласованность на уровне всего проекта (если он контролируется одним вендором)

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

Ближе к концу статьи станет понятно почему. Можно также спускаться от файла к уровню функции или одного statement, но это, в нашем случае, уже лишняя детализация.

Какому-то из разработчиков нравится вариант A, в то время как второму по вкусу B. В Go не так много идентичных операций, имеющих разное написание (синтаксическое различие), но всё же простор для разногласий найдётся. Использование любой формы операции допустимо и не является ошибкой, но использование более, чем одной, формы, может вредить консистентности кода. Оба варианта валидны и имеют своих сторонников.

Как вы думаете, какой из этих двух способов создать слайс длины 100 используется большинством Go программистов?

// (A)
new([100]T)[:] // (B)
(&[100]T)[:]

Ответ

В реальном коде я ни разу не видел использования ни одного из них. Ни один из вариантов не является предпочтительным.

А использовать в этом случае стоит make([]T, 100).

Одиночный import

Импортирование единственного пакета можно выполнить двумя способами:

// (A) без скобочек
import "github.com/go-lintpack/lintpack" // (B) со скобочками
import ( "github.com/go-lintpack/lintpack"
)

Скорее всего, в вашем проекте встречаются оба варианта. При этом ни gofmt, ни goimports, не выполняют преобразование из одной формы в другую.

Выделение указателя под нулевое значение

Пока в Go есть встроенная функция new и альтернативные способы получить указатель на новый объект, вы будете встречать как new(T), так и &T{}.

// (A) использование функции new
new(T)
new([]T) // (B) взятие адреса от литерала
&T{}
&[]T{}

Создание пустого слайса

Для создания пустого слайса (не путать с nil-слайсом) есть как минимум два популярных способа:

// (A) использование функции make
make([]T, 0) // (A) литерал слайса
[]T{}

Создание пустой хеш-таблицы

Соответственно, разграничение здесь как минимум не избыточно. Возможно, вам покажется разделение на создание пустого слайса и map не очень логичным, но не все люди, которые предпочитают []T{}, будут использовать map[K]V{} вместо make(map[K]V).

// (A) использование функции make
make(map[K]V) // (B) литерал хеш-таблицы
map[K]V{}

Шестнадцатеричные литералы

// (A) a-f, нижний регистр
0xff // (B) A-F, верхний регистр
0xFF

Такое должно быть найдено статическим анализатором (линтером). Написание типа 0xFf, со смешенным регистром — это уже не о консистентности. Попробуйте, например, gocritic. Каким?

Проверка на вхождение в диапазон

Исходный код, который будет выражать это ограничение так записан быть не может. В математике (и некоторых языках программирования, но не в Go) вы могли бы описать диапазон как low < x < high. При этом есть как минимум два популярных способа оформить проверку вхождения в диапазон:

// (A) выравнивание по левой стороне
x > low && x < high // (B) выравнивание по центру
low < x && x < high

Оператор and-not

Называется он and-not и выполняет он ту же операцию, что и &, применённый к результату ^ от правого (второго) операнда. Вы знали, что в Go есть бинарный оператор &^?

// (A) использование оператора &^ (нет пробела)
x &^ y // (B) использование & с ^ (есть пробел)
x & ^y

В конце концов, если выбор в пользу &^ был бы единогласным, то это должно было бы быть проверкой линтера, а не частью выбора в вопросе согласованности кода. Я проводил опрос, чтобы убедиться, что здесь вообще могут быть разные предпочтения. К моему удивлению, сторонники нашлись у обеих форм.

Литералы вещественных чисел

Есть множество способов записать вещественный литерал, но одна из самых часто встречающихся особенностей, которая может ломать консистентность даже внутри одной функции — это стиль записи целой и вещественной части (сокращённый или полный).

// (A) явная целая и вещественная части
0.0
1.0 // (B) неявная целая и вещественная части (краткая запись)
.0
1.

LABEL или label?

Всё, что остаётся, выбрать один из возможных способов и придерживаться ему. К сожалению, устоявшихся конвенций к именованию меток, нет.

// (A) всё в верхнем регистре
LABEL_NAME: goto PLEASE // (B) upper camel case
LabelName: goto TryTo // (C) lower camel case
labelName: goto beConsistent

Скорее всего, такого варианта придерживаться не стоит. Возможен также snake_case, но нигде, кроме Go ассемблера, я таких меток не видел.

Указание типа для untyped числового литерала

// (A) тип находится слева от "="
var x int32 = 10
const y float32 = 1.6 // (B) тип находится справа от "="
var x = int32(10)
const y = float32(1.6)

Перенос закрывающей скобки вызываемой функции

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

// (A) закрывающая скобка на одной строке с последним аргументом
multiLineCall( a, b, c) // (B) закрывающая скобка переносится на новую строку
multiLineCall( a, b, c,
)

Выше мы перечислили список эквивалентных операций.

Наиболее простой вариант ответа: ту, которая имеет более высокую частоту использования в рассматриваемой части проекта (как частный случай, во всём проекте). Как определить, какую из них нужно использовать?

Программа go-consistent анализирует указанные файлы и пакеты, подсчитывая количество использования той или иной альтернативы, предлагая заменить менее частые формы на идиоматичные в рамках анализируемой части проекта, те, у которых частота максимальна.

Прямолинейный подсчёт

Иногда это приводит к тому, что один файл диктует стиль всего пакета только из-за того, что в нём эта операция является более часто используемой. На данный момент вес у каждого вхождения равен единице. Особенно это ощутимо в отношении редких операций, вроде создания пустого map.

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

Если $(go env GOPATH)/bin находится в системном PATH, то следующая команда установит go-consistent:

go get -v github.com/Quasilyte/go-consistent
go-consistent --help # Для проверки установки

Возвращаясь к границам консистентности, вот как проверить каждую из них:

  • Проверить консистентность внутри одного файла можно с помощью запуска go-consistent на этом файле
  • Консистентность внутри пакета вычисляется при запуске с одним аргументом-пакетом (или с указанием всех файлов этого пакета)
  • Вычисление глобальной консистентности потребует передачи всех пакетов в качестве аргументов

go-consistent устроен таким образом, что может выдать ответ даже для огромных репозиториев, где загрузить все пакеты в память единовременно довольно сложно (по крайней мере, на персональной машине без огромного количества оперативной памяти).

Запуск go-consistent без каких-либо флагов и конфигурационных файлов — это то, что работает для 99% случаев. Другая важная особенность — это zero configuration.

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

Снизить консистентность кода может непоследовательное именование параметров функции или локальных переменных.

А как насчёт s против str? Большинству Go программистов очевидно, что erro менее удачное имя для ошибки, чем err.

Здесь сложно обойтись без манифеста локальных конвенций. Задачу проверки консистентности имён переменных решить методами go-consistent нельзя.

go-namecheck определяет формат этого манифеста и позволяет проводить его валидацию, упрощая следование определённым в проекте нормам именования сущностей.

Например, можно указать, что для параметров функций, имеющих тип string, стоит использовать идентификатор s вместо str.

Выражается это правило следующий образом:

{"string": {"param": {"str": "s"}}}

  • string — регулярное выражение, которое захватывает интересующий тип
  • param — область применимости правил замены (scope). Их может быть несколько
  • Пара "str": "s" указывает на замену с str на s. Их может быть несколько

Вот, например, правило, которое требует замены префикса re у переменных типа *regexp. Вместо замены 1-к-1 можно использовать регулярное выражение, которое захватывает более чем один идентификатор. Другими словами, вместо reFile правило требовало бы использовать fileRE. Regexp на суффикс RE.

{ "regexp\\.Regexp": { "local+global": {"^re[A-Z]\\w*$": "use RE suffix instead of re prefix"} }
}

Любой уровень косвенности будет удалён, поэтому не нужно определять отдельные правила для указателей на тип и самого типа. Все типы рассматриваются с игнорированием указателей.

Файл, который описывал бы оба правила, выглядел бы так:

{ "string": { "param": { "str": "s", "strval": "s" }, }, "regexp\\.Regexp": { "local+global": {"^re[A-Z]\\w*$": "use RE suffix instead of re prefix"} }
}

Затем, в определённый момент, на code review, происходит запрос на переименование переменной или поля в структуре. Предполагается, что проект начинается с пустого файла. В следующий раз проблему можно будет находить автоматически. Естественной реакцией может быть просьба закрепить эти, ранее неформальные, требования, в виде верифицируемого правила в файле конвенций именования.

Устанавливается и используется go-namecheck аналогично go-consistent, за исключением лишь того, что для получения корректного результата вам не нужно запускать проверку на всём множестве пакетов и файлов.

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

Обратная связь — по-настоящему ценный подарок для меня. Если по описаниям выше go-consistent или go-namecheck вам понравились, попробуйте запустить их на своих проектах.

Важно: если у вас есть какая-то идея или дополнение, пожалуйста, скажите об этом!
Есть несколько способов:

  • Напишите в комментариях к этой статье
  • Создайте issue go-consistent
  • Реализуйте свою утилиту и дайте миру о ней знать

Запуск раз в месяц с последующей правкой всех несоответствий может быть более удачным решением. Предупреждение: добавлять go-consistent и/или go-namecheck в CI может быть чересчур радикальным действием.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть