golang

Design by Contract на минималках: пишем assertions и улучшаем устойчивость на Go

  • суббота, 8 марта 2025 г. в 00:00:13
https://habr.com/ru/companies/yadro/articles/888374/

Всем привет! Меня зовут Александр Иванов, я старший разработчик в YADRO, работаю над созданием средств управления элементами опорной сети и пишу на Go. Мы с командой разрабатываем продукт для сервисов сотовой связи — качество нашей работы влияет на пользовательский опыт тысяч людей. Поэтому часто мы ищем решения, как повысить устойчивость работы кода в продакшене. 

Об одном из таких решений я расскажу в этой статье. Design by Contract — подход к проектированию ПО, в котором взаимодействие компонентов системы основано на контрактах. Контракты описывают ожидания вызываемой и вызывающей функции и улучшают производительность кода.

Защита методами Defensive Development

Часто при написании кода одновременно вызывающая сторона и вызываемая функция подстраховываются и реализуют так называемую «защиту от дурака». Такой подход еще часто называют Defensive Development.

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

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

package main

import (
   	"errors"
   	"fmt"

   	"another"
)

var (
   	a       	int = 1
   	param       	= &a
)
 
func main() {
   	if err := another.Start(); err != nil {
          	fmt.Print(err)
   	}

    	param = nil
   	if err := another.Start(); err != nil {
          	fmt.Print(err)
   	}
}

func Start() error {
   	if param == nil {
          	return ErrParamNil
   	}
 
   	return Сontinue(param)
}
package another

import (
   	"errors"
   	"fmt"
)


var (
   	ErrParamNil 	= errors.New("\"param\" should not be nil")
)

func Continue(param *int) error {
   	if param == nil {
          	return ErrParamNil
   	}
    	fmt.Printf("dereferenced \"param\" is %d\n", *param)
   	return nil
}

Обратите внимание, какие проверки делаются в вызывающей функции Start и в вызываемой функции Continue. И там, и там переменная param проверяется на равенство nil — налицо избыток проверок. 

Если мысленно поместить содержимое функции continue в вызывающую ее функцию Start, проще говоря, сделать inline, то получим примерно такой код:

func Start() error {
   	if param == nil {
          	return ErrParamNil
   	}
 
   	if param == nil {
          	return ErrParamNil
   	}
 
   	fmt.Printf("dereferenced \"param\" is %d\n", *param)
   	return nil
}

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

Тогда что нам помешало не добавлять в программу лишние проверки на этапе написания кода? 

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

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

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

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

Альтернативный вариант — Design by Contract

Оказывается, да. Такая идея заложена в основу подхода к проектированию программного обеспечения Design by Contract (DbC). Этот термин предложил Бертран Мейер еще в 1986 году, работая над языком программирования Eiffel. 

Существуют и другие варианты названия этого подхода, например programming by contract и contract-based programming. В русском языке часто используют термин «контрактное программирование».

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

Предусловия и постусловия в DbC

С одной стороны, в соответствии с правилами контракта, вызываемая сторона — это поставщик сервиса. Он вправе ожидать определенного поведения от клиента-пользователя. Обязательство клиента, которое защищает поставщика, называется предусловием. Оно определяет условия, которым должен удовлетворять клиент перед запросом в сервис поставщика.

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

Как устроены нарушения:

  • нарушение предусловия предупреждает о баге на стороне клиента, который не соблюдает свою часть контракта,

  • нарушение постусловия предупреждает о баге на стороне поставщика, который, очевидно, неправильно выполняет свою работу.

В оригинальной версии DbC частью контракта могут являться еще и инварианты класса —  мета-требования, которые неявно добавляются ко всем предусловиям и постусловиям. Однако их реализация на Go требует дополнительных усилий или даже поддержки компилятора. Чтобы не сбивать вас с толку, инварианты класса в этой статье мы рассматривать не будем.

Ширина контракта

Если вернуться к коду на Go из первой части статьи, то становится понятно, что проверка параметра param на равенство nil внутри вызываемой функции cntnue является своего рода проверкой выполнения контракта, который можно было бы описать словами:

«При вызове не стоит переживать о том, передается ли указатель в параметре param, ведь внутри функции cntnue присутствует проверка на равенство его значению nil. На такой случай реализована соответствующая реакция».

То есть функция Continue накладывает очень нестрогие требования на параметр param, кроме того, что param может указывать на целочисленную переменную. Через такой указатель в функцию можно передать довольно широкий спектр «кривых» значений.

Такого рода контракты, защищающие вызываемый код «от дурака», называются широкими контрактами.

Но если существуют широкие контракты, значит, существуют и узкие? Да, узкие контракты — это контракты, которые обозначают предусловия. Они возлагают ответственность на вызывающую сторону, чтобы внутри функции не было много лишнего кода, который бы затруднял понимание логики функции и замедлял работу.

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

Существует библиотека, которая уже реализует удобные механизмы для применения DbC на Golang. Но с иллюстративной целью далее мы напишем крохотный велосипед пакет assertion, который своей легкостью не отвлечет читателя от идеи и в то же время даст точное понимание о возможности применения DbC в программах на Golang.

Пишем свои assertions

Давайте создадим подпапку нового пакета assertion и поместим туда три файла, которые и будут реализовывать наш новый самодельный пакет assertions: assertion_enabled.go, assertion_disabled.go и go.mod.

//go:build enable_assert
 
// Package assertion provides method for asserting your code
package assertion
 import (
        	"fmt"
        	"log"
)
 const panicMessage = "Assertion happened"
 
// Assert will panic in case when condition is not true
func Assert(condition bool, format ...any) {
        	if !condition {
                    	s := fmt.Sprintf(format[0].(string), format[1:])
                    	log.Fatal(s)
        	}
}
//go:build !enable_assert
 
// Package assertion provides method for asserting your code
package assertion
 
// Assert will print error log in case when condition is not true
func Assert(condition bool, format ...any) {
        	// do nothing
        	return
}
module assertion
 
go 1.23

Нетрудно догадаться из названий файлов, что пакет assertion подразумевает условную компиляцию на основе тега "enable_assert". В случае если тег задан в командной строке компилятора, скомпилируется код для дебага и тестирования. Он содержит инструкции для логирования случаев невыполнения условий проверки и прекращения программы паникой.

Если же тег "enable_assert" не задан в командной строке компилятора, то сгенерируется код, в котором функция Assert пустая. В случае невыполнения условий проверки программа продолжает работать как ни в чем не бывало и когда-нибудь обязательно упадет.

Кому-то покажется странным, что при невыполнении условий проверки программа продолжает работать в продакшене. Но это одна из идей контактов: компилировать варианты для тестирования и дебага способом, отличным от того, который собирает программу в продакшене.

Важно запомнить в качестве аксиомы: «Правильно написанный код не должен приводить к невыполнению условий контракта».

В продакшен можно добавить логирование warning, чтобы повысить удобство при разборах полетов. Но еще раз повторю: программу нужно протестировать настолько хорошо, чтобы выявить все несоответствия контрактов на этапе отладки, а вероятность Assert в релизе свести к нулю.

Антипаттерны использования Assert

Использовать Assert вместо if err != nil { return err}

Одно из неправильных применений Assert — это замена им проверки, которая действительно должна быть и на которую нужно реализовать реакцию в коде.

Выполнять вычисления при вызове Assert

Еще одна распространенная и трудно выявляемая ошибка — это выполнение вычислений и присваивание значений переменным прямо при вызове Assert, которые могут быть упразднены при оптимизации кода компилятором:

  •  e.g. Assert(i++ > 0, “осторожно, не факт, что в релизе i увеличится”),

  •  Assert(call_to_f1(), “осторожно, не факт, что call_to_f1() будет вызвана в релизе”).

Удалять Assert, несмотря на то, что это часть описания контракта

Непонимание, что Assert — это реализация контракта, может привести к тому, что разработчик, незнакомый с DbC, захочет просто удалить проверку. Однако нужно всегда помнить, что срабатывание Assert говорит о нарушении контракта одной из сторон. То есть, если срабатывает Assert, надо прежде всего найти баг и пофиксить. А уж если контракт действительно должен быть изменен, Assert подскажет, где находятся участки кода, на которые нужно обратить внимание.

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

В прошлой реализации пакета (Go 1.23) fmt-функция Printf всегда возвращает err = nil. И практически все игнорируют возвращающееся значение ошибки, тогда как могли бы проверять постусловие assertion.Assert (err == nil). Так, рано или поздно в последующих версиях можно научить код реагировать на err, отличный от nil.

А что ждет разработчиков в реализации Go 1.24? Разберемся на Go-митапе 13 марта, где я выступаю экспертом в дискуссии «Go 1.24 — куда движется язык?». 

Мы обсудим важнейшие изменения: от механизма отслеживания зависимостей до слабых указателей (weak pointers) в стандартной библиотеке. Узнаем, как флаг -json, улучшенные финализаторы и пакет crypto/mlkem могут изменить подход к разработке.

Регистрируйтесь и присоединяйтесь к онлайн-трансляции →

Defensive Development vs DbC

Когда разработчики переходят с Defensive Development на DbC, они пытаются полностью перевести код на контракты. Это не всегда хорошо, поэтому лучше умело сочетать оба подхода. Фанатизм не приветствуется, если сам язык программирования того не требует. Например, внешние API можно оставить в духе Defensive Development, а весь внутренний код писать с применением контрактного программирования.

Unit-tests vs DbC

Нужно использовать все способы повышения качества. Например, проверить время выполнения callback с помощью unit-test — трудозатратная работа, тогда как с проверка через assert — задача на минуту. Единственное «но»: проблема будет обнаружена во время тестовых прогонов у написавшего «долгий» callback.

t := time.Now()
	cb()
	Assert(time.Since(t) < 1*time.Second, "cb handling is too long")

Преимущества работы через Assert 

  • Самодокументированный код. Как и многие другие реализации Assert, наша assertion.Assert следом за условием, вторым аргументом позволяет добавить текст, который может довольно подробно пояснить, почему проверка не прошла.

  • Читабельность кода. Убирая из кода трехстрочные if-then-else и меняя на одну строку с поясняющим Assert, получаем более лаконичный и удобочитаемый код.

  • Улучшенная работа с кэшем CPU. В релизной компиляции из кода исчезнут ненужные jump и branch, что ускорит программу, снизив cache-misses.

  • Облегчение анализа причины падения. Появляется единое место в коде, куда можно добавить вывод call stack’а в логи, — функция Assert.

  • Поддержание консистентности кода и модели. При использовании генераторов подход контрактного программирования можно использовать для проверки соответствия реализации, зависимой от некой модели, и самой модели, из которой генерируются части реализации.

  • Проверка условий, накладываемых на callback-функции. Легкий способ обезопасить работоспособность своей библиотеки от неожиданных действий пользователя.

Как оптимизировать код на Go с помощью Garbage Collector? Читайте в моей статье →