Так поступают и разработчики, которые, не прекращая профессионально развиваться, постоянно стремятся найти ответ на самый важный свой вопрос: Как писать хороший код?. Вот что говорит об этом Фредерик Брукс в книге Мифический человеко-месяц, или Как создаются программные системы:
Программист, подобно поэту, работает с чистой мыслью. Он строит свои замки в воздухе и из воздуха, творя силой воображения. Трудно найти другой материал, используемый в творчестве, который столь же гибок, прост для шлифовки или переработки и доступен для воплощения грандиозных замыслов.
Как писать хороший код (источник)
В этом материале сделана попытка найти ответ на большой вопрос из вышеприведенного комикса. Самый простой способ писать хороший код заключается в том, чтобы не употреблять в своих программах так называемые антипаттерны.
Что такое антипаттерны
Антипаттерны проникают в код тогда, когда программы пишут, не принимая во внимание вопросы, связанные с их использованием в будущем. Может случиться так, что то, что называют анти-паттерном, в определенный момент может показаться приемлемым решением некоей задачи. Но, на самом деле, по мере роста кодовой базы подобные решения оказываются малооправданными, увеличивая технический долг проектов.
В качестве простого примера проникновения в код антипаттерна можно привести ситуацию, когда при создании API не учитывается то, как именно потребитель этого API будет им пользоваться. Этому посвящён наш первый рассказ об антипаттерне.
Знание антипаттернов и сознательное предотвращение их использования в ходе написания кода это, без сомнения, чрезвычайно важный шаг на пути к улучшению читабельности и поддерживаемости кодовой базы.
Рассмотрим некоторые распространённые антипаттерны, встречающиеся в коде, написанном на Go.
1. Возврат значения неэкспортируемого типа из экспортируемой функции
В Go, для экспорта любого поля или любой переменной, нужно, чтобы имя поля или переменной начиналось бы с большой буквы. Сущности экспортируют из пакетов для того чтобы они были бы видны другим пакетам. Например, если в программе нужно воспользоваться константой
Pi
из пакета math
, то
обращаться к ней надо с помощью конструкции math.Pi
.
Использование конструкции math.pi
приведет к
возникновению ошибки.Имена (это относится к полям структур, к функциям, к переменным), которые начинаются с маленькой буквы, являются неэкспортируемыми, они видны только в пакете, в котором они объявлены.
Если экспортируемая функция возвращает значение неэкспортируемого типа это может привести к неудобствам, так как тому, кто вызывает эту функцию из другого пакета, придётся самостоятельно определить тип этого значения для его использования.
// Не рекомендованоtype unexportedType stringfunc ExportedFunc() unexportedType {return unexportedType("some string")}// Рекомендованоtype ExportedType stringfunc ExportedFunc() ExportedType {return ExportedType("some string")}
2. Неоправданное использование пустых идентификаторов
В целом ряде ситуаций присвоение значений пустому идентификатору нецелесообразно. Вот, например, что сказано в спецификации Go об использовании пустого идентификатора в циклах
for
:Если последней итерационной переменной является пустой идентификатор, то выражение range эквивалентно такому же выражению без этого идентификатора.
// Не рекомендованоfor _ = range sequence{run()}x, _ := someMap[key]_ = <-ch// Рекомендованоfor range something{run()}x := someMap[key]<-ch
3. Использование циклов или нескольких вызовов append для объединения срезов
Если нужно объединить пару срезов нет нужды перебирать один из них в цикле и присоединять элементы к другому срезу по одному. Вместо этого гораздо лучше и эффективнее будет сделать это в одном вызове функции
append
.В следующем примере объединение срезов выполняется путём перебора элементов
sliceTwo
и присоединения этих элементов к
sliceOne
по одному:
for _, v := range sliceTwo {sliceOne = append(sliceOne, v)}
Но известно, что
append
это вариативная функция, а это
значит, что её можно вызывать с разным количеством аргументов. В
результате предыдущий пример можно значительно упростить и
переписать с использованием функции append
:
sliceOne = append(sliceOne, sliceTwo)
4. Избыточные аргументы в вызовах make
В Go имеется особая встроенная функция
make
, которая
используется для создания и инициализации объектов типов
map
(ассоциативный массив), slice
(срез),
chan
(канал). Для инициализации среза с использованием
make
нужно предоставить этой функции, в виде
аргументов, тип среза, его длину и емкость. При инициализации
ассоциативного массива с помощью make
нужно передать
функции размер этого массива.Правда, пользуясь
make
, нужно знать о том, что у этой
функции уже имеются значения, назначаемые соответствующим
аргументам по умолчанию:- В случае с каналами емкость буфера устанавливается в 0 (речь идёт о небуферизованном канале).
- В случае с ассоциативными массивами размер по умолчанию устанавливается в небольшое начальное значение.
- В случае со срезами емкость по умолчанию устанавливается в значение, равное указанной длине среза.
Вот неудачный пример использования
make
:
ch = make(chan int, 0)sl = make([]int, 1, 1)
Этот код можно переписать так:
ch = make(chan int)sl = make([]int, 1)
Надо отметить, что использование именованных констант при создании каналов не считается анти-паттерном в тех случаях, когда речь идёт об отладке, о применении результатов неких вычислений, о написании кода, жёстко привязанного к какой-либо платформе.
const c = 0ch = make(chan int, c) // Это не антипаттерн
5. Ненужное выражение return в функциях
Не рекомендуется ставить в конец функции выражение
return
в том случае, если функция ничего не
возвращает.
// Бесполезное выражение return, не рекомендованоfunc alwaysPrintFoofoo() {fmt.Println("foofoo")return}// Рекомендованоfunc alwaysPrintFoo() {fmt.Println("foofoo")}
При этом надо отметить, что возврат с помощью
return
именованных возвращаемых значений не стоит путать с бесполезным
использованием return
. Например, в следующем фрагменте
кода return
возвращает именованное значение:
func printAndReturnFoofoo() (foofoo string) {foofoo := "foofoo"fmt.Println(foofoo)return}
6. Ненужные команды break в выражениях switch
В Go выражения
switch
устроены так, что при выполнении
одного из вариантов кода, описываемого в блоке case
,
код блоков case
, которые следуют за ним, выполняться
не будет. В других языках, наподобие C, выполнение кода должно быть
явным образом прервано с помощью команды break
. В
противном случае, если, например, в switch
нет ни
одного break
, после выполнения кода одного блока
case
выполняется и код следующих за ним блоков.
Известно, что эта возможность в выражениях switch
используется очень редко и обычно вызывает ошибки. В результате
многие современные языки программирования, вроде Go, отказались от
такой схемы выполнения выражений switch
.В результате в конце блоков
case
нет необходимости
пользоваться командами break
. Это значит, что оба
нижеприведенных примера дают один и тот же результат.
// Не рекомендованоswitch s {case 1:fmt.Println("case one")breakcase 2:fmt.Println("case two")}// Рекомендованоswitch s {case 1:fmt.Println("case one")case 2:fmt.Println("case two")}
Но, если нужно, в
switch
можно реализовать переход к
последовательному выполнению кода блоков case
. Для
этого используется команда fallthrough
. Например,
следующий код выведет 23
:
switch 2 {case 1:fmt.Print("1")fallthroughcase 2:fmt.Print("2")fallthroughcase 3:fmt.Print("3")}
7. Отказ от использования стандартных вспомогательных функций для решения распространённых задач
В Go существуют краткие варианты определенных функций, вызываемых с особым набором аргументов. Эти варианты функций можно использовать для повышения эффективности программ, для улучшения их читабельности, для того чтобы сделать их понятнее.
Например, в Go, для организации ожидания завершения выполнения нескольких горутин, можно использовать счетчик
sync.WaitGroup
. При работе с ним могут применяться
вспомогательные функции. В частности функция wg.Add()
(переменная wg
в наших примерах имеет тип
sync.WaitGroup
), позволяющая добавить нужное
количество горутин в группу. Когда горутина из группы завершает
выполнение, счетчик уменьшают, вызывая функцию
wg.Add()
с передачей ей -1
:
wg.Add(1)// ...какой-то кодwg.Add(-1)
Если говорить о конструкции
wg.Add(-1)
, то, вместо
того, чтобы использовать её для ручного декрементирования счетчика,
можно воспользоваться функцией wg.Done()
, которая тоже
декрементирует счетчик, уменьшая его значение на 1, но при этом
выглядит лучше и понятнее, чем wg.Add(-1)
:
wg.Add(1)// ... какой-то кодwg.Done()
8. Избыточные проверки на nil при работе со срезами
Длина нулевого (
nil
) среза приводится к 0. Это значит,
что не нужно проверять срез на nil
перед проверкой его
длины.Например, в следующем фрагменте кода проверка на
nil
избыточна:
if x != nil && len(x) != 0 {// выполняем какие-то действия}
Этот код можно переписать, убрав из него проверку на
nil
:
if len(x) != 0 {// выполняем какие-то действия}
9. Ненужные функциональные литералы
Если в теле функционального литерала нет ничего кроме обращения к единственной функции, то от этого литерала можно, без ущерба для возможностей программы, отказаться. Например:
fn := func(x int, y int) int { return add(x, y) }
Этот код можно улучшить, вынеся
add
из функционального
литерала:
fn := add
10. Использование единственного блока case в выражениях select
Выражения
select
используются при работе с каналами.
Обычно они включают в себя несколько блоков case
. Но в
том случае, если речь идёт об обработке единственной операции,
представленной единственным блоком case
, использование
выражения select
оказывается избыточным. В подобной
ситуации можно просто воспользоваться операциями отправки данных в
канал или их получения из канала:
// Не рекомендованоselect {case x := <-ch:fmt.Println(x)}// Рекомендованоx := <-chfmt.Println(x)
В выражении
select
может применяться блок
default
, код которого выполняется в том случае, если
системе не удаётся подобрать подходящий блок case
.
Использование default
позволяет создавать
неблокирующие выражения select
:
select {case x := <-ch:fmt.Println(x)default:fmt.Println("default")}
11. Параметр типа context.Context, который не является первым параметром функции, в которой используется этот параметр
Если функция имеет параметр типа
context.Context
, то
ему обычно дают имя ctx
, а при объявлении функции его
следует ставить первым в списке параметров. Такой аргумент
используется в Go-функциях достаточно часто, а подобные аргументы,
с логической точки зрения, лучше размещать в начале или в конце
списка аргументов. Почему?Это помогает разработчикам не забывать об этих аргументах благодаря единообразному подходу к их использованию в различных функциях. Вариативные функции в Go объявляют с использованием конструкции вида
elems ...Type
, которая должна располагаться в
конце списка их параметров. В результате рекомендуется делать
параметр типа context.Context
первым параметром
функции. Подобные соглашения имеются и в других проектах, например,
в среде Node.js первым параметром, который передают коллбэкам,
принято делать объект ошибки.
// Не рекомендованоfunc badPatternFunc(k favContextKey, ctx context.Context) {// выполняем какие-то действия}// Рекомендованоfunc goodPatternFunc(ctx context.Context, k favContextKey) {// выполняем какие-то действия}
На что стоит обратить внимание тем, кто хочет писать хороший код на Go?