company_banner

Разбираемся с интерфейсами в Go

Автор оригинала: Alex Edwards
  • Перевод
  • Tutorial

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

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

Так что такое интерфейс?


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

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

type Stringer interface {
    String() string
}

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

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

type Book struct {
    Title  string
    Author string
}

func (b Book) String() string {
    return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}

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

А вот другой пример. Тип Count тоже удовлетворяет интерфейсу fmt.Stringer, потому что у него есть метод с тем же сигнатурным строковым значением String().

type Count int

func (c Count) String() string {
    return strconv.Itoa(int(c))
}

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

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

А теперь самое важное.

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

Допустим, у нас есть функция:

func WriteLog(s fmt.Stringer) {
    log.Println(s.String())
}

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

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

Давайте соберём всё сказанное в один пример, демонстрирующий мощь интерфейсов.

package main

import (
    "fmt"
    "strconv"
    "log"
)

// Объявляем тип Book, который удовлетворяет интерфейсу fmt.Stringer.
type Book struct {
    Title  string
    Author string
}

func (b Book) String() string {
    return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}

// Объявляем тип Count, который удовлетворяет интерфейсу fmt.Stringer.
type Count int

func (c Count) String() string {
    return strconv.Itoa(int(c))
}

// Объявляем функцию WriteLog(), которая берёт любой объект,
// удовлетворяющий интерфейсу fmt.Stringer в виде параметра.
func WriteLog(s fmt.Stringer) {
    log.Println(s.String())
}

func main() {
    // Инициализируем объект Book и передаём в WriteLog().
    book := Book{"Alice in Wonderland", "Lewis Carrol"}
    WriteLog(book)

    // Инициализируем объект Count и передаём в WriteLog().
    count := Count(3)
    WriteLog(count)
}

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

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

2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol
2009/11/10 23:00:00 3

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

Чем полезны интерфейсы?


Есть целый ряд причин, по которым вы можете начать использовать интерфейсы в Go. И по моему опыту, самые важные из них такие:

  1. Интерфейсы помогают уменьшить дублирование, то есть количество шаблонного кода.
  2. Они облегчают использование в модульных тестах заглушек вместо реальных объектов.
  3. Будучи архитектурным инструментом, интерфейсы помогают отвязывать части вашей кодовой базы.

Рассмотрим подробнее эти способы использования интерфейсов.

Уменьшение количества шаблонного кода


Пусть у нас есть структура Customer, содержащая какие-то данные о клиенте. В одной части кода мы хотим записывать эту информацию в bytes.Buffer, а в другой части хотим записывать данные о клиенте в os.File на диске. Но, в обоих случаях, мы хотим сначала сериализовать структуру Сustomer в 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().

Простая реализация:

package main

import (
    "encoding/json"
    "io"
    "log"
    "os"
)

// Создаём тип Customer.
type Customer struct {
    Name string
    Age  int
}

// Реализуем метод WriteJSON, который берёт io.Writer в виде параметра.
// Он отправляет структуру Сustomer в JSON, и если всё отрабатывает 
// успешно, то вызывается соответствующий метод Write() из io.Writer.
func (c *Customer) WriteJSON(w io.Writer) error {
    js, err := json.Marshal(c)
    if err != nil {
        return err
    }

    _, err = w.Write(js)
    return err
}

func main() {
    // Инициализируем структуру Customer.
    c := &Customer{Name: "Alice", Age: 21}

    // Затем с помощью Buffer можем вызвать метод WriteJSON
    var buf bytes.Buffer
    err := c.WriteJSON(buf)
    if err != nil {
        log.Fatal(err)
    }

    // или воспользоваться файлом.
    f, err := os.Create("/tmp/customer")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()


    err = c.WriteJSON(f)
    if err != nil {
        log.Fatal(err)
    }
}

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

Но если вы новичок в Go, у вас возникнет пара вопросов: «Как узнать, что интерфейс io.Writer вообще существует? И как заранее узнать, что ему удовлетворяют bytes.Buffer и os.File?»

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

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

Модульное тестирование и заглушки


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

Допустим, у вас есть магазин, и вы храните в PostgreSQL информацию о продажах и количестве клиентов. Вы хотите написать код, вычисляющий долю продаж (удельное количество продаж на одного клиента) за последние сутки, округлённую до двух знаков после запятой.

Минимальная реализация будет выглядеть так:

// Файл: main.go
package main

import (
    "fmt"
    "log"
    "time"
    "database/sql"
    _ "github.com/lib/pq"
)

type ShopDB struct {
    *sql.DB
}

func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
    var count int
    err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
    return count, err
}

func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
    var count int
    err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
    return count, err
}

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    shopDB := &ShopDB{db}
    sr, err := calculateSalesRate(shopDB)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf(sr)
}

func calculateSalesRate(sdb *ShopDB) (string, error) {
    since := time.Now().Sub(24 * time.Hour)

    sales, err := sdb.CountSales(since)
    if err != nil {
        return "", err
    }

    customers, err := sdb.CountCustomers(since)
    if err != nil {
        return "", err
    }

    rate := float64(sales) / float64(customers)
    return fmt.Sprintf("%.2f", rate), nil
}

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

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

И на помощь приходят интерфейсы!

Мы создадим собственный интерфейсный тип, описывающий методы CountSales() и CountCustomers(), на которые опирается функция calculateSalesRate(). Затем обновим сигнатуру calculateSalesRate(), чтобы использовать этот интерфейсный тип в качестве параметра вместо прописанного типа *ShopDB.

Вот так:

// Файл: main.go
package main

import (
    "fmt"
    "log"
    "time"
    "database/sql"
    _ "github.com/lib/pq"
)

// Создаём свой интерфейс ShopModel. Он прекрасно подходит для
// интерфейса с описанием нескольких методов, и он должен описывать
// входные параметры-типы, а также типы возвращаемых значений.
type ShopModel interface {
    CountCustomers(time.Time) (int, error)
    CountSales(time.Time) (int, error)
}

// Тип ShopDB удовлетворяет новому интерфейсу ShopModel, потому что
// у него есть два необходимых метода -- CountCustomers() и CountSales().
type ShopDB struct {
    *sql.DB
}

func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
    var count int
    err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
    return count, err
}

func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
    var count int
    err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
    return count, err
}

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    shopDB := &ShopDB{db}

    sr := calculateSalesRate(shopDB)
    fmt.Printf(sr)
}

// Заменим это для использования интерфейсного типа ShopModel в виде параметра
// вместо прописанного типа *ShopDB.
func calculateSalesRate(sm ShopModel) string {
    since := time.Now().Sub(24 * time.Hour)

    sales, err := sm.CountSales(since)
    if err != nil {
        return "", err
    }

    customers, err := sm.CountCustomers(since)
    if err != nil {
        return "", err
    }

    rate := float64(sales) / float64(customers)
    return fmt.Sprintf("%.2f", rate), nil
}

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

// Файлы: main_test.go
package main

import (
    "testing"
)

type MockShopDB struct{}

func (m *MockShopDB) CountCustomers() (int, error) {
    return 1000, nil
}

func (m *MockShopDB) CountSales() (int, error) {
    return 333, nil
}

func TestCalculateSalesRate(t *testing.T) {
    // Инициализируем заглушку.
    m := &MockShopDB{}
    // Передаём заглушку в функцию calculateSalesRate().
    sr := calculateSalesRate(m)

    // Проверяем, соответствует ли возвращаемое значение ожиданиям на основе
    // фальшивых входных данных.
    exp := "0.33"
    if sr != exp {
        t.Fatalf("got %v; expected %v", sr, exp)
    }
}

Теперь запускаем тест и всё прекрасно работает.

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


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

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

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

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


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

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

Пустой интерфейсный тип не описывает методы. У него нет правил. И поэтому любой объект удовлетворяет пустому интерфейсу.

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

Рассмотрим код:

package main

import "fmt"


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

    person["name"] = "Alice"
    person["age"] = 21
    person["height"] = 167.64

    fmt.Printf("%+v", person)
}

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

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

map[age:21 height:167.64 name:Alice]

Когда речь заходит об извлечении и использовании значений из map’ы, важно помнить вот о чём. Допустим, вы хотите получить значение age и увеличить его на 1. Если вы напишете подобный код, то он не скомпилируется:

package main

import "log"

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

    person["name"] = "Alice"
    person["age"] = 21
    person["height"] = 167.64

    person["age"] = person["age"] + 1

    fmt.Printf("%+v", person)
}

Вы получите сообщение об ошибке:

invalid operation: person["age"] + 1 (mismatched types interface {} and int)

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

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

package main

import "log"

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

    person["name"] = "Alice"
    person["age"] = 21
    person["height"] = 167.64

    age, ok := person["age"].(int)
    if !ok {
        log.Fatal("could not assert value to int")
        return
    }

    person["age"] = age + 1

    log.Printf("%+v", person)
}

Если вы запустите это, все будет работать как полагается:

2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice]

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

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

type Person struct {
    Name   string
    Age    int
    Height float32
}

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

Полезные интерфейсные типы


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


Также здесь доступен более длинный список стандартных библиотек.
Mail.ru Group
1 017,47
Строим Интернет
Поделиться публикацией

Похожие публикации

Комментарии 17

    0
    «Увы и Ах», многие беженцы с других языков используют пустые интерфейсы по сути для отмены типизации. Это неидеоматично, уродливо, небезопасно и медленно.
    Пару раз пытался увещевать: «мол, не делайте так, сделайте лучше нормальный интерфес». В ответ лишь непонимание…
      0

      А может пытаются таким образом компенсировать отсутствие дженериков?

        +2

        Если бы []SomeType удовлетворяло []SomeInterface (при том что SomeType удовлетворяет SomeInterface), то в большинстве случаев не понадобилась бы ни такая компенсация, ни разработка дженериков в самом языке.

          0

          Ну это справедливо лишь для boxed типов.

          0
          А может пытаются таким образом компенсировать отсутствие дженериков?

          0. Может быть вы имели ввиду наследование? Это, бывает, что нужно, да.
          1. Но чистые generic же нужны для очень небольших кусков алгоритмов (типа универсальная сортировка на все случаи жизни)
          2. Это же можно компенсировать в ряде случаев определенными интерфейсами (кстати, посмотрите как это реализовано в сортировке в Go).
          3. Но используют везде — даже в тех случаях, когда не нужно.

          Просто силь людей, пришедших с языков с динамической типизацией, и не обращающих внимание, что в статической типизации многие вещи делаются иначе.
            0
            0. Так вроде наследование есть в Go.
            1. Я считаю что дженерики хорошо подходят именно в библиотеках, к примеру в стандартной библиотеке Java или C++ их довольно много (особенно при реализации базовых интерфейсов Map, List и т.д) и позволяет обходится без interface{}-хаков. А в Go стандартная библиотека изобилует interface{}-конструкциями, далеко ходить не надо — fmt.PrintLn к примеру, или пакет text/template, где во внутренней реализации их тоже много. Я понимаю что это похоже на C-way, где для этого применяют void*, но не думаю что это хорошая практика в современном программировании.
            2. Утиная типизация из интерфейсов хороша, но как показывает практика использования interface{} в библиотеках — не всегда применима.

            Просто силь людей, пришедших с языков с динамической типизацией

            Согласен, но дженерики всё таки появились в статических языках, когда статическая типизация достигла своего потолка возможностей. Вспомнить хотя бы Java в эпоху до версии 5.0, где повсюду был Object-ад с приведениями типов. Да, в прикладном коде дженерики почти не используются, если только не реализуешь свои абстрактные типы данных или алгоритмы, но в библиотеках это must have.
              –1
              Так вроде наследование есть в Go.

              В Go нет наследования, потому что в современном понимании — это не объектный язык. Есть только имплементация интерфейса (утиная типизация), и встраивание типов. Ни одно из них по своей сути наследованием не является.

                0
                По своей сути это всё таки наследование, просто в других, не ООП рамках.
              0

              Однако с дженериками не так просто. Они нужны постоянно, просто у нас есть два встроенных хака в виде массивов и map.

          0
          Это похоже на то, что в функциональных языках называют классы типов (type classes). Верно?
            +1
            type classes намного более экспрессивны чем interface'ы
            Можно много говорить, вот читайте сами: stackoverflow.com/questions/6948166/javas-interface-and-haskells-type-class-differences-and-similarities
            Но вот маленький пример (из ответа на стэке):
            it's really hard to make things like add :: t -> t -> t with an interface, where it is polymorphic on more than one parameter, because there's no way for the interface to specify that the argument type and return type of the method is the same type as the type of the object it is called on

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

              А с этим случайно не помогут контракты из недавно предложенного варианта дженериков для го?

                –1
                не знаю насколько контракты это смогут решить (к сожалению не осилил proposal, так как «многа букаф»)
                0
                /del
                0

                Нет. Классы типов — это совсем другая концепция.


                1. Контракт (класс типов) — отдельно
                2. Тип — отдельно
                3. Реализация контракта (экземпляр класса типов) для типа — отдельно
                  0
                  Судя по коду в этой статье, все 3 пункта соблюдаются. Я же не говорю что это тоже самое, но ведь похоже?
                    0

                    Не соблюдаются и не похоже.
                    Никакой отдельной реализации контракта для типа нет.

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

              Самое читаемое