...

пятница, 9 августа 2019 г.

[Перевод] Разбираемся с интерфейсами в Go


В последние несколько месяцев я проводил исследование, в котором спрашивал людей, что им трудно понять в 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.

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


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

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий