Использование интерфейсов связано с расходами. Что это за стоимость? Давай попробуем кое-что из этого поработать.

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

type getter interface {
	get() int
}
type zero struct{}
//go:noinline
func (z zero) get() int {
	return 0
}

Мы делаем очень простой тест с двумя подтестами. Один вызывает get через getter, другой вызывает get конкретный zero тип напрямую.

func BenchmarkInterfaceCallSimple(b *testing.B) {
	var z zero
	var g getter
	g = z
	b.Run("via interface", func(b *testing.B) {
		total := 0
		for i := 0; i < b.N; i++ {
			total += g.get()
		}
		if total > 0 {
			b.Logf("total is %d", total)
		}
	})
	b.Run("direct", func(b *testing.B) {
		total := 0
		for i := 0; i < b.N; i++ {
			total += z.get()
		}
		if total > 0 {
			b.Logf("total is %d", total)
		}
	})
}

Вот результат.

BenchmarkInterfaceCallSimple/via_interface-8	4.63 ns/op
BenchmarkInterfaceCallSimple/direct-8       	2.44 ns/op

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

Давай попробуем что-нибудь немного другое. Мы создадим очень простую реализацию io.Reader, которая заполняет буфер нулями.

type zeroReader struct{}
func (z zeroReader) Read(p []byte) (n int, err error) {
	for i := range p {
		p[i] = 0
	}
	return len(p), nil
}

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

func BenchmarkInterfaceAlloc(b *testing.B) {
	var z zeroReader
	var r io.Reader
	r = z
	b.Run("via interface", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			var buf [7]byte
			r.Read(buf[:])
		}
	})
	b.Run("direct", func(b *testing.B) {
		b.ReportAllocs()
		for i := 0; i < b.N; i++ {
			var buf [7]byte
			z.Read(buf[:])
		}
	})
}

Вот результаты. Вместо примерно 2 нс накладных расходов теперь мы ближе к 20 нс и 1 выделению. Что тут происходит?

BenchmarkInterfaceAlloc/via_interface-8   	50000000	    24.5 ns/op	    8 B/op	     1 allocs/op
BenchmarkInterfaceAlloc/direct-8          	300000000	    5.52 ns/op	    0 B/op	     0 allocs/op

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

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

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

Реализации не должны сохранять p.

Точно так же json.Unmarshaler описание интерфейса подразумевает, что буфер на самом деле не должен ускользать.

UnmarshalJSON должен скопировать данные JSON, если он хочет сохранить данные после возврата.

Было бы неплохо, если бы мы могли выразить это в определении интерфейса таким образом, чтобы компилятор мог понять?