Начало
В течение последних нескольких месяцев я проводил опрос, в котором спрашивал людей, что им кажется сложным в изучении Go. И одна из вещей, которая постоянно упоминалась, — это концепция интерфейсов.
Я понимаю это. Go был первым языком, который я использовал, имеющим интерфейсы, и я помню, что в то время вся эта концепция казалась мне довольно запутанной. Поэтому в этом уроке я хочу помочь всем, кто находится в такой же ситуации, и сделать несколько вещей:
- Дать понятное объяснение того, что такое интерфейсы;
- Объяснить, почему они полезны и как вы можете использовать их в своем коде;
- Обсудим, что такое
interface{}
(пустой интерфейс) иany
; - И просмотрим некоторые полезные типы интерфейсов, которые вы найдете в стандартной библиотеке.
Что такое интерфейс в Go?
Тип интерфейса в Go похож на определение. Он определяет и описывает точные методы, которые должны иметь какой-либо другой тип.
Одним из примеров типа интерфейса из стандартной библиотеки является интерфейс fmt.Stringer, который выглядит следующим образом:
type Stringer interface {
String() string
}
Мы говорим, что что-то удовлетворяет этому интерфейсу (или реализует этот интерфейс), если оно имеет метод с точной сигнатурой String() string.
Например, следующий тип Book
удовлетворяет интерфейсу, поскольку имеет метод String() string
:
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
.
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()
может безопасно вызывать.
Давайте объединим все это в одном примере, который даст нам представление о возможностях интерфейсов.
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() и записывает результат в журнал.
Если вы запустите код, вы должны получить результат, который будет выглядеть примерно так:
$ go run main.go
2025/08/26 15:00:42 Книга: «Мастер и Маргарита» - Михаил Булгаков
2025/08/26 15:00:42 10
Я не хочу слишком углубляться в эту тему. Но главное, что нужно запомнить, это то, что, используя тип интерфейса в объявлении нашей функции WriteLog()
, мы сделали эту функцию независимой (или гибкой) от точного типа объекта, который она принимает. Важно только то, какие методы она имеет.
Почему они полезны?
Существует множество причин, по которым вы можете в конечном итоге использовать интерфейс в Go, но, по моему опыту, три наиболее распространенные из них:
- Чтобы помочь уменьшить дублирование или шаблонный код.
- Чтобы упростить использование макетов/моков вместо реальных объектов в модульных тестах.
- В качестве архитектурного инструмента, помогающего обеспечить развязку между частями вашей кодовой базы.
Давайте рассмотрим эти три варианта использования и изучим их немного подробнее.
Сокращение шаблонного/boilerplate кода
Представим, что у нас есть структура Customer
, содержащая некоторые данные о клиенте. В одной части нашего кода мы хотим записать информацию о клиенте в bytes.Buffer, а в другой части — в os.File на диске. Но в обоих случаях мы сначала хотим сериализовать структуру клиента в JSON.
Это сценарий, в котором мы можем использовать интерфейсы Go для сокращения количества шаблонного кода.
Первое, что вам нужно знать, это то, что в Go есть интерфейсный тип io.Writer, который выглядит следующим образом:
type Writer interface {
Write(p []byte) (n int, err error)
}
И мы можем использовать тот факт, что и bytes.Buffer, и тип os.File удовлетворяют этому интерфейсу, поскольку они имеют методы bytes.Buffer.Write() и os.File.Write() соответственно.
Давайте посмотрим на простую реализацию:
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 часа. Пример, разумеется ни в коем случае нельзя применять в реальных системах, но он нам поможет разобраться как всё устроено.
Минимальная реализация кода для этого может выглядеть примерно так:
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
.
Вот так:
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()
работает правильно. Вот так:
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{}
похож на джокер. Где бы вы его ни увидели в объявлении (например, переменная, параметр функции или поле структуры), вы можете использовать объект любого типа.
Взгляните на следующий код:
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
) в качестве значений карты — и это нормально. Поскольку объекты любого типа удовлетворяют пустому интерфейсу, этот код будет работать без проблем.
Вы можете попробовать это здесь, и при запуске вы должны увидеть вывод, который будет выглядеть примерно так:
map[age:21 height:198 name:Иван]
Но есть важный момент, на который стоит обратить внимание при извлечении и использовании значения из этой карты.
Например, допустим, мы хотим получить значение "age"
и увеличить его на 1. Если вы напишете что-то вроде следующего кода, он не скомпилируется:
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}
И вы получите следующее сообщение об ошибке:
./prog.go:13:18: invalid operation: person["age"] + 1 (mismatched types interface{} and int)
Это происходит потому, что значение, хранящееся в карте, принимает тип interface{} и теряет свой исходный базовый тип int. Поскольку оно больше не имеет тип int, мы не можем прибавить к нему 1.
Чтобы обойти это, вам нужно привести значение обратно к типу int
перед его использованием. Вот так:
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}
Если вы запустите это сейчас, всё должно работать как ожидается:
map[age:31 height:198 name:Иван]
Так когда же стоит использовать пустой интерфейс в своём коде?
Скорее всего, использовать пустой интерфейс стоит не так уж часто. Если вы собираетесь применить его, остановитесь и подумайте, действительно ли interface{}
— лучший вариант. Как правило, код становится более понятным, безопасным и производительным, если использовать конкретные типы или непустые интерфейсы. В приведённом выше примере было бы разумнее определить структуру Person
с соответствующими типизированными полями, например так:
type Person struct {
Name string
Age int
Height float32
}
Тем не менее, пустой интерфейс полезен в ситуациях, когда вам нужно принимать и работать с непредсказуемыми или определяемыми пользователем типами. Вы встретите его использование во многих местах стандартной библиотеки именно по этой причине, например, в функциях gob.Encode, fmt.Print и template.Execute.
Идентификатор any
В Go начиная с версии 1.18 появился идентификатор any, который является синонимом для типа пустого интерфейса interface{}
. Это просто более короткая и читаемая запись, которую рекомендуется использовать вместо interface{}
в новых проектах.
Например, следующие объявления эквивалентны:
var x interface{}
var y any
Использование any
делает код более понятным, особенно при работе с обобщениями (generics
) и ситуациями, когда требуется значение любого типа.
Общие и полезные типы интерфейсов
Вот несколько интерфейсов из стандартной библиотеки Go, которые часто используются и с которыми полезно ознакомиться:
io.Reader
: описывает объекты, из которых можно читать данные.io.Writer
: описывает объекты, в которые можно записывать данные.fmt.Stringer
: описывает объекты, которые могут быть представлены как строка.error
: стандартный интерфейс для ошибок.http.Handler
: используется для обработки HTTP-запросов.context.Context
: передаёт информацию о контексте выполнения (тайм-ауты, отмена и т.д.).sort.Interface
: позволяет сортировать пользовательские коллекции.
Изучение этих интерфейсов поможет вам писать более гибкий и переиспользуемый код, а также лучше понимать архитектуру Go-приложений.
Комментарии