Исправление ошибки в миллиард долларов в Go за счет заимствования у Rust

panic: runtime error: invalid memory address or nil pointer dereference

Если вы когда-либо использовали Go, вы, вероятно, видели эту ошибку хотя бы один раз. Где-то указатель nil или интерфейс nil был передан функции, которая не обрабатывает nil. Во всех случаях это ошибка программирования: либо функция должна обрабатывать nil, либо вызывающий объект не должен передавать функции nil. В этом отчете об опыте работы с Go будет предпринята попытка обосновать тот факт, что nil часто не нужен, а необходимость иметь указатели и интерфейсы, допускающие нулевое значение, может вызвать панику в производственной среде. Мы также кратко обсудим, как Rust решает эту проблему и как их решение может быть применено к Go.

Ноль может быть полезным

Давайте сначала начнем с того, что покажем, почему допускать нулевое значение может быть полезно. Основной вариант использования nil - указать, что значение «отсутствует». Хорошим примером этого является некоторый код, который анализирует JSON и должен знать, было ли предоставлено поле или нет. Используя указатель на int, вы можете отличить отсутствующий ключ от значения, равного 0:

package main

import (
	"encoding/json"
	"fmt"
)

type Number struct {
	N int
}

type NilableNumber struct {
	N *int
}

func main() {
	zeroJSON := []byte(`{"N": 0}`)
	emptyJSON := []byte(`{}`)

	var zeroNumber Number
	json.Unmarshal(zeroJSON, &zeroNumber)
	var emptyNumber Number
	json.Unmarshal(emptyJSON, &emptyNumber)
	fmt.Println(zeroNumber.N, emptyNumber.N) // output: 0 0

	var zeroNilable NilableNumber
	json.Unmarshal(zeroJSON, &zeroNilable)
	var emptyNilable NilableNumber
	json.Unmarshal(emptyJSON, &emptyNilable)
	fmt.Println(*zeroNilable.N, emptyNilable.N) // output: 0 
}

Но у него есть свои недостатки

Однако, хотя nil может быть полезной концепцией, у него также есть много недостатков. Тони Хоар, изобретатель «нулевых ссылок», даже называет это своей ошибкой на миллиард долларов:

Нулевые ссылки были созданы в 1964 году - сколько они стоят? Меньше или больше миллиарда долларов? Хотя мы не знаем, сумма, вероятно, составляет порядка (американского) миллиарда - более одной десятой миллиарда, менее десяти миллиардов.
Источник: Тони Хоар - Нулевые ссылки: Ошибка в миллиард долларов

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

Было бы неплохо создать такую ​​переменную, потому что и указатели, и интерфейсы, очевидно, имеют другие варианты использования, кроме кодирования отсутствующего значения. Указатели позволяют изменять переменную на месте, а интерфейсы позволяют указывать абстракцию. Иногда вам нужен один из этих вариантов использования, но не нужно пропущенное значение. Поскольку нет способа закодировать это в системе типов, вам необходимо использовать указатель или тип интерфейса, который может быть нулевым. Тогда возникает проблема: как читатель кода узнает, разрешено ли переменной быть нулевым или нет?

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

Сделайте нулевое значение полезным.
Источник: Роб Пайк - Гоферфест - 18 ноября 2015 г.

Примеры проблемы

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

type Named interface {
	Name() string
}

func greeting(thing Named) string {
	return "Hello " + thing.Name()
}

Этот код выглядит нормально, но если вы вызываете приветствие с nil, код компилируется нормально:

func main() {
	greeting(nil)
}

Однако во время выполнения вы получите нашу хорошо известную ошибку «разыменование нулевого указателя»:

panic: runtime error: invalid memory address or nil pointer dereference

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

type myNumber struct {
	n int
}

func plusOne(number *myNumber) {
	number.n++
}

Но снова при вызове его с nil он будет компилироваться нормально, но с ошибкой во время выполнения:

func main() {
	var number *myNumber
	plusOne(number)
}

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

Обходные пути

Один из способов решения этой проблемы - просто задокументировать, что вы не должны передавать nil функции. Хорошим примером этого является пакет `context` в стандартной библиотеке Go. В документации указано следующее:

Не передавать нулевой контекст, даже если это разрешено функцией. Передайте context.TODO, если не знаете, какой контекст использовать.
Источник: https://golang.org/pkg/context/

Очевидно, что это не совсем надежное решение.

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

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

func greeting(thing Named) (string, error) {
	if thing == nil {
		return "", errors.New("thing cannot be nil")
	}

	return "Hello " + thing.Name()
}

Решение?

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

Я покажу одно возможное решение. Я не думаю, что это лучшее возможное решение, но я все равно хотел бы показать его, потому что оно может быть реализовано с минимальным количеством новых языковых функций и может быть интегрировано в существующий код Go шаг за шагом. Однако это решение только для нулевых указателей, а не для нулевых интерфейсов. Идея очень проста и также используется Rust и C ++: добавить тип указателя, который никогда не может быть нулевым. Ниже приведен пример кода, в котором я использую символ `&` для определения указателя, не имеющего нулевого значения, функция `plusOne` теперь будет выглядеть так:

func plusOne(number &myNumber) {
	number.n++
}

Тогда у вас будет следующее поведение:

func TestNil() {
	var number *myNumber
	plusOne(number) // compile error: cannot use *myNumber as &myNumber, *myNumber can be nil
}

func TestPointer() {
	var number *myNumber = &myNumber{n: 5}
	plusOne(number) // compile error: cannot use *myNumber as &myNumber, *myNumber can be nil
}

func TestNonNilablePointer() {
	var number &myNumber = &myNumber{n: 5}
	plusOne(number)
	fmt.Println(number.n) // output: 6
}

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

func plusOnePointer(numberPointer *myNumber) error {
	if numberPointer == nil {
		return errors.New("number shouldn't be nil")
	}

	number := numberPointer.(*myNumber)
	plusOne(number)

}

func TestCastedPointer() {
	var number *myNumber = &myNumber{n: 5}

	plusOnePointer(number) // should handle error here
	fmt.Println(number.n)  // output: 6
}

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

Заключение

Бывают случаи, когда вы не хотите, чтобы указатель или интерфейс когда-либо были нулевыми. В этих случаях о проверке на nil легко забыть, что может привести к панике в производственном коде. Обходные пути либо ненадежны, либо их сложно применять строго. Из-за всего этого было бы неплохо, если бы Go получил поддержку на уровне языка для указателей и интерфейсов, которые никогда не равны нулю.

Некоторые заключительные мысли

В Go есть больше типов, которые могут быть нулевыми. Некоторые типы продолжают работать нормально, если они обычно используются, когда они равны нулю, например срезы и каналы. Другие, такие как карты, будут паниковать при использовании, если они равны нулю, точно так же, как указатели и интерфейсы. Обсуждение этих типов было исключено из этого поста как для того, чтобы он был короче, так и потому, что мы не сталкивались с производственным сбоями из-за использования nil для этих типов. Тем не менее, вероятно, неплохо иметь в виду и эти типы при разработке решения.