Начало

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

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

  1. Дать понятное объяснение того, что такое интерфейсы;
  2. Объяснить, почему они полезны и как вы можете использовать их в своем коде;
  3. Обсудим, что такое interface{} (пустой интерфейс) и any;
  4. И просмотрим некоторые полезные типы интерфейсов, которые вы найдете в стандартной библиотеке.

Что такое интерфейс в Go?

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

Одним из примеров типа интерфейса из стандартной библиотеки является интерфейс fmt.Stringer, который выглядит следующим образом:

GO
type Stringer interface {
    String() string
}
Нажмите, чтобы развернуть и увидеть больше

Мы говорим, что что-то удовлетворяет этому интерфейсу (или реализует этот интерфейс), если оно имеет метод с точной сигнатурой String() string.

Например, следующий тип Book удовлетворяет интерфейсу, поскольку имеет метод String() string:

GO
type Book struct {
    Title  string
    Author string
}

func (b Book) String() string {
    return fmt.Sprintf("Книга: «%s» - %s", b.Title, b.Author)
}
Нажмите, чтобы развернуть и увидеть больше

Неважно, что представляет собой этот тип Book и что он делает. Важно только то, что он имеет метод String(), который возвращает значение типа string.

Или, как другой пример, следующий тип Count также удовлетворяет интерфейсу fmt.Stringer — опять же потому, что он имеет метод с точной сигнатурой String() string.

GO
type Count int

func (c Count) String() string {
    return strconv.Itoa(int(c))
}
Нажмите, чтобы развернуть и увидеть больше

Важно понимать, что у нас есть два разных типа, Book и Count, которые выполняют разные функции. Но их общей чертой является то, что оба они удовлетворяют интерфейсу fmt.Stringer.

Можно посмотреть на это и с другой стороны. Если вы знаете, что объект удовлетворяет интерфейсу fmt.Stringer, вы можете быть уверены, что у него есть метод с точной сигнатурой String() string, который вы можете вызвать.

Например, предположим, что у вас есть следующая функция:

GO
func WriteLog(s fmt.Stringer) {
    log.Print(s.String())
}
Нажмите, чтобы развернуть и увидеть больше

Поскольку функция WriteLog() использует тип интерфейса fmt.Stringer в объявлении параметра, мы можем передать любой объект, который удовлетворяет интерфейсу fmt.Stringer. Например, мы можем передать любой из типов Book и Count, которые мы создали ранее, в метод WriteLog(), и код будет работать нормально.

Кроме того, поскольку передаваемый объект удовлетворяет интерфейсу fmt.Stringer, мы знаем, что он имеет метод String(), который функция WriteLog() может безопасно вызывать.

Давайте объединим все это в одном примере, который даст нам представление о возможностях интерфейсов.

GO
 1package main
 2
 3import (
 4	"fmt"
 5	"log"
 6	"strconv"
 7)
 8
 9// Book тип, который удовлетворяет интерфейсу fmt.Stringer.
10type Book struct {
11	Title  string
12	Author string
13}
14
15func (b Book) String() string {
16	return fmt.Sprintf("Книга: «%s» - %s", b.Title, b.Author)
17}
18
19// Count тип, который удовлетворяет интерфейсу fmt.Stringer.
20type Count int
21
22func (c Count) String() string {
23	return strconv.Itoa(int(c))
24}
25
26// WriteLog функция, которая принимает в качестве параметра любой объект,
27// удовлетворяющий интерфейсу fmt.Stringer.
28func WriteLog(s fmt.Stringer) {
29	log.Print(s.String())
30}
31
32func main() {
33	// Инициализируем объект Count и передаём его в WriteLog().
34	book := Book{"Мастер и Маргарита", "Михаил Булгаков"}
35	WriteLog(book)
36
37	// Инициализируем объект Count и передаём его в WriteLog().
38	count := Count(10)
39	WriteLog(count)
40}
Нажмите, чтобы развернуть и увидеть больше

Это довольно круто. В основной функции мы создали разные типы Book и Count, но передали оба в одну и ту же функцию WriteLog(). В свою очередь, она вызывает соответствующую функцию String() и записывает результат в журнал.

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

CONSOLE
$ go run main.go
2025/08/26 15:00:42 Книга: «Мастер и Маргарита» - Михаил Булгаков
2025/08/26 15:00:42 10
Нажмите, чтобы развернуть и увидеть больше

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

Почему они полезны?

Существует множество причин, по которым вы можете в конечном итоге использовать интерфейс в Go, но, по моему опыту, три наиболее распространенные из них:

  1. Чтобы помочь уменьшить дублирование или шаблонный код.
  2. Чтобы упростить использование макетов/моков вместо реальных объектов в модульных тестах.
  3. В качестве архитектурного инструмента, помогающего обеспечить развязку между частями вашей кодовой базы.

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

Сокращение шаблонного/boilerplate кода

Представим, что у нас есть структура Customer, содержащая некоторые данные о клиенте. В одной части нашего кода мы хотим записать информацию о клиенте в bytes.Buffer, а в другой части — в os.File на диске. Но в обоих случаях мы сначала хотим сериализовать структуру клиента в JSON.

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

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

GO
type Writer interface {
        Write(p []byte) (n int, err error)
}
Нажмите, чтобы развернуть и увидеть больше

И мы можем использовать тот факт, что и bytes.Buffer, и тип os.File удовлетворяют этому интерфейсу, поскольку они имеют методы bytes.Buffer.Write() и os.File.Write() соответственно.

Давайте посмотрим на простую реализацию:

GO
 1package main
 2
 3import (
 4	"bytes"
 5	"encoding/json"
 6	"io"
 7	"log"
 8	"os"
 9)
10
11// Создаём тип Customer
12type Customer struct {
13	Name string
14	Age  int
15}
16
17// Реализуйте метод WriteJSON, который принимает io.Writer в качестве параметра.
18// Он выполняет преобразование структуры customer в JSON, и если преобразование прошло успешно,
19// то вызывает метод Write() соответствующего io.Writer.
20func (c *Customer) WriteJSON(w io.Writer) error {
21	js, err := json.Marshal(c)
22	if err != nil {
23		return err
24	}
25
26	_, err = w.Write(js)
27	return err
28}
29
30func main() {
31	// Инициализируем структуру Customer.
32	c := Customer{
33		Name: "Иван",
34		Age:  30,
35	}
36
37	// Затем мы можем вызвать метод WriteJSON, используя буфер...
38	var buf bytes.Buffer
39	err := c.WriteJSON(&buf)
40	if err != nil {
41		log.Fatal(err)
42	}
43
44	// Или используя файл.
45	f, err := os.Create("/tmp/customer.json")
46	if err != nil {
47		log.Fatal(err)
48	}
49	defer f.Close()
50
51	err = c.WriteJSON(f)
52	if err != nil {
53		log.Fatal(err)
54	}
55}
Нажмите, чтобы развернуть и увидеть больше

Конечно, это всего лишь простой пример (и есть другие способы структурировать код для достижения того же конечного результата). Но он хорошо иллюстрирует преимущество использования интерфейса — мы можем создать метод Customer.WriteJSON() один раз и вызывать его в любое время, когда нам нужно записать что-либо, что удовлетворяет интерфейсу io.Writer.

Но если вы новичок в Go, у вас все равно останется несколько вопросов:

Откуда вы знаете, что интерфейс io.Writer вообще существует? И как вы заранее знаете, что bytes.Buffer и os.File соответствуют ему?

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

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

Модульное (unit) тестирование и мокирование

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

Допустим, нам необходимо посчитать среднюю температуру по больнице. Мы имеет таблицу пациентов и таблицу температур. Считать будем среднюю температуру за последние 24 часа. Пример, разумеется ни в коем случае нельзя применять в реальных системах, но он нам поможет разобраться как всё устроено.

Минимальная реализация кода для этого может выглядеть примерно так:

main.go
 1package main
 2
 3import (
 4	"database/sql"
 5	"fmt"
 6	"log"
 7	"time"
 8)
 9
10type HospitalDB struct {
11	*sql.DB
12}
13
14func (hdb *HospitalDB) CountPatients(since time.Time) (int, error) {
15	var count int
16	err := hdb.QueryRow("SELECT COUNT(*) FROM patients WHERE created_at > $1", since).Scan(&count)
17	return count, err
18}
19
20func (hdb *HospitalDB) SumTemperatures(since time.Time) (float64, error) {
21	var temperature float64
22	err := hdb.QueryRow("SELECT SUM(temperature) FROM temperatures WHERE created_at > $1", since).Scan(&temperature)
23	return temperature, err
24}
25
26func main() {
27	db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/db")
28	if err != nil {
29		log.Fatal(err)
30	}
31	defer db.Close()
32
33	mySiteDB := &HospitalDB{db}
34	avgTemperature, err := calculateAverageTemperature(mySiteDB)
35	if err != nil {
36		log.Fatal(err)
37	}
38	fmt.Println(avgTemperature)
39}
40
41func calculateAverageTemperature(hdb *HospitalDB) (string, error) {
42	since := time.Now().Add(-24 * time.Hour)
43
44	sumTemperature, err := hdb.SumTemperatures(since)
45	if err != nil {
46		return "", err
47	}
48
49	patients, err := hdb.CountPatients(since)
50	if err != nil {
51		return "", err
52	}
53
54	avgTemperature := sumTemperature / float64(patients)
55	return fmt.Sprintf("%.2f", avgTemperature), nil
56}
Нажмите, чтобы развернуть и увидеть больше

А что, если мы хотим создать модульный тест для функции calculateAverageTemperature(), чтобы убедиться, что математическая логика в ней работает правильно?

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

Итак, что же мы можем сделать? Вы угадали — на помощь приходят интерфейсы!

Решением здесь будет создать собственный тип интерфейса, который описывает методы CountPatients() и SumTemperatures(), от которых зависит функция calculateAverageTemperature(). Затем мы можем обновить сигнатуру calculateAverageTemperature(), чтобы использовать этот пользовательский тип интерфейса в качестве параметра вместо конкретного типа *HospitalDB.

Вот так:

main.go
 1package main
 2
 3import (
 4	"database/sql"
 5	"fmt"
 6	"log"
 7	"time"
 8)
 9
10// Создадим собственный интерфейс HospitalModel. Обратите внимание,
11// что интерфейс может описывать несколько методов, а также типы
12// входных параметров и типы возвращаемых значений.
13type HospitalModel interface {
14	CountPatients(since time.Time) (int, error)
15	SumTemperatures(since time.Time) (float64, error)
16}
17
18// Тип HospitalDB удовлетворяет нашему новому пользовательскому интерфейсу HospitalModel,
19// поскольку он имеет два необходимых метода -- CountPatients() и SumTemperatures().
20type HospitalDB struct {
21	*sql.DB
22}
23
24func (hdb *HospitalDB) CountPatients(since time.Time) (int, error) {
25	var count int
26	err := hdb.QueryRow("SELECT COUNT(*) FROM patients WHERE created_at > $1", since).Scan(&count)
27	return count, err
28}
29
30func (hdb *HospitalDB) SumTemperatures(since time.Time) (float64, error) {
31	var temperature float64
32	err := hdb.QueryRow("SELECT SUM(temperature) FROM temperatures WHERE created_at > $1", since).Scan(&temperature)
33	return temperature, err
34}
35
36func main() {
37	db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/db")
38	if err != nil {
39		log.Fatal(err)
40	}
41	defer db.Close()
42
43	mySiteDB := &HospitalDB{db}
44	avgTemperature, err := calculateAverageTemperature(mySiteDB)
45	if err != nil {
46		log.Fatal(err)
47	}
48	fmt.Println(avgTemperature)
49}
50
51// Заменяем здесь, используя тип интерфейса HospitalModel в качестве параметра вместо конкретного типа *HospitalDB.
52func calculateAverageTemperature(hm HospitalModel) (string, error) {
53	since := time.Now().Add(-24 * time.Hour)
54
55	sumTemperature, err := hm.SumTemperatures(since)
56	if err != nil {
57		return "", err
58	}
59
60	patients, err := hm.CountPatients(since)
61	if err != nil {
62		return "", err
63	}
64
65	avgTemperature := sumTemperature / float64(patients)
66	return fmt.Sprintf("%.2f", avgTemperature), nil
67}
Нажмите, чтобы развернуть и увидеть больше

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

main_test.go
 1package main
 2
 3import (
 4	"testing"
 5	"time"
 6)
 7
 8type MockHospitalDB struct{}
 9
10func (m *MockHospitalDB) CountPatients(_ time.Time) (int, error) {
11	return 1000, nil
12}
13
14func (m *MockHospitalDB) SumTemperatures(_ time.Time) (float64, error) {
15	return 36600, nil
16}
17
18func TestCalculateAverageTemperature(t *testing.T) {
19	m := &MockHospitalDB{}
20	avgTemperature, err := calculateAverageTemperature(m)
21	if err != nil {
22		t.Fatal(err)
23	}
24	exp := "36.60"
25	if avgTemperature != exp {
26		t.Errorf("got %v; expected %v", avgTemperature, exp)
27	}
28}
Нажмите, чтобы развернуть и увидеть больше

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

Архитектура приложения

В предыдущих примерах мы увидели, как интерфейсы могут быть использованы для того, чтобы отделить определённые части вашего кода от зависимости от конкретных типов. Например, функция calculateAverageTemperature() полностью гибкая в отношении того, что вы передаёте ей — единственное, что имеет значение, это то, что переданный объект удовлетворяет интерфейсу HospitalModel.

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

Допустим, вы создаёте веб-приложение, которое взаимодействует с базой данных. Если вы создадите интерфейс, описывающий точные методы для работы с базой данных, вы сможете ссылаться на этот интерфейс во всех ваших HTTP-обработчиках вместо конкретного типа. Поскольку HTTP-обработчики ссылаются только на интерфейс, это помогает разделить слой HTTP и слой взаимодействия с базой данных. Это упрощает работу с каждым из слоёв отдельно и позволяет в будущем заменить один слой, не затрагивая другой.

Я писал об этом шаблоне в [предыдущей статье в блоге][#], где более подробно рассмотрел тему и привёл несколько практических примеров кода.

Что такое пустой интерфейс?

Если вы программируете на Go уже некоторое время, то, вероятно, сталкивались с типом пустого интерфейса: interface{}. Это может быть немного запутанным, но я постараюсь объяснить это здесь.

В начале этой статьи в блоге я сказал:

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

Тип пустого интерфейса по сути не описывает никаких методов. У него нет правил. И из-за этого следует, что любой объект удовлетворяет пустому интерфейсу.

Или, говоря проще, тип пустого интерфейса interface{} похож на джокер. Где бы вы его ни увидели в объявлении (например, переменная, параметр функции или поле структуры), вы можете использовать объект любого типа.

Взгляните на следующий код:

GO
package main

import "fmt"


func main() {
    person := make(map[string]interface{}, 0)

    person["name"] = "Иван"
    person["age"] = 30
    person["height"] = 198

    fmt.Printf("%+v", person)
}
Нажмите, чтобы развернуть и увидеть больше

В этом фрагменте кода мы инициализируем карту person, которая использует тип string для ключей и тип пустого интерфейса interface{} для значений. Мы присвоили значения трёх разных типов (string, int и float32) в качестве значений карты — и это нормально. Поскольку объекты любого типа удовлетворяют пустому интерфейсу, этот код будет работать без проблем.

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

CONSOLE
map[age:21 height:198 name:Иван]
Нажмите, чтобы развернуть и увидеть больше

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

Например, допустим, мы хотим получить значение "age" и увеличить его на 1. Если вы напишете что-то вроде следующего кода, он не скомпилируется:

GO
 1package main
 2
 3import "fmt"
 4
 5
 6func main() {
 7    person := make(map[string]interface{}, 0)
 8
 9    person["name"] = "Иван"
10    person["age"] = 30
11    person["height"] = 198
12
13    person["age"] = person["age"] + 1
14
15    fmt.Printf("%+v", person)
16}
Нажмите, чтобы развернуть и увидеть больше

И вы получите следующее сообщение об ошибке:

CONOSOLE
./prog.go:13:18: invalid operation: person["age"] + 1 (mismatched types interface{} and int)
Нажмите, чтобы развернуть и увидеть больше

Это происходит потому, что значение, хранящееся в карте, принимает тип interface{} и теряет свой исходный базовый тип int. Поскольку оно больше не имеет тип int, мы не можем прибавить к нему 1.

Чтобы обойти это, вам нужно привести значение обратно к типу int перед его использованием. Вот так:

GO
 1package main
 2
 3import "fmt"
 4
 5func main() {
 6	person := make(map[string]interface{}, 0)
 7
 8	person["name"] = "Иван"
 9	person["age"] = 30
10	person["height"] = 198
11
12	age, ok := person["age"].(int)
13	if !ok {
14		fmt.Println("age is not int")
15		return
16	}
17
18	person["age"] = age + 1
19
20	fmt.Printf("%+v", person)
21}
Нажмите, чтобы развернуть и увидеть больше

Если вы запустите это сейчас, всё должно работать как ожидается:

CONOSOLE
map[age:31 height:198 name:Иван]
Нажмите, чтобы развернуть и увидеть больше

Так когда же стоит использовать пустой интерфейс в своём коде?

Скорее всего, использовать пустой интерфейс стоит не так уж часто. Если вы собираетесь применить его, остановитесь и подумайте, действительно ли interface{} — лучший вариант. Как правило, код становится более понятным, безопасным и производительным, если использовать конкретные типы или непустые интерфейсы. В приведённом выше примере было бы разумнее определить структуру Person с соответствующими типизированными полями, например так:

GO
type Person struct {
    Name   string
    Age    int
    Height float32
}
Нажмите, чтобы развернуть и увидеть больше

Тем не менее, пустой интерфейс полезен в ситуациях, когда вам нужно принимать и работать с непредсказуемыми или определяемыми пользователем типами. Вы встретите его использование во многих местах стандартной библиотеки именно по этой причине, например, в функциях gob.Encode, fmt.Print и template.Execute.

Идентификатор any

В Go начиная с версии 1.18 появился идентификатор any, который является синонимом для типа пустого интерфейса interface{}. Это просто более короткая и читаемая запись, которую рекомендуется использовать вместо interface{} в новых проектах.

Например, следующие объявления эквивалентны:

GO
var x interface{}
var y any
Нажмите, чтобы развернуть и увидеть больше

Использование any делает код более понятным, особенно при работе с обобщениями (generics) и ситуациями, когда требуется значение любого типа.

Общие и полезные типы интерфейсов

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

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

Авторское право

Автор: Арсений Соколов

Ссылка: https://cdn.arsen.pw/posts/interfaces-explained/

Лицензия: CC BY-NC-SA 4.0

Эта работа лицензирована в соответствии с международной лицензией Creative Commons Attribution-NonCommercial-ShareAlike 4.0. Пожалуйста, указывайте источник, используйте в некоммерческих целях и сохраняйте ту же лицензию.

Комментарии

Начать поиск

Введите ключевые слова для поиска статей

↑↓
ESC
⌘K Горячая клавиша