null

Go вместе изучать Go. Часть 5

Введение

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

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

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

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

Для начала давайте рассмотрим простой пример:

type I interface {
	M()
}

Здесь мы определили интерфейс I с одним методом M(). Любой тип, который имеет метод M() с такой же сигнатурой, будет реализовывать этот интерфейс — причём совершенно автоматически, без каких-либо явных деклараций.

Неявная реализация интерфейсов

Одна из самых уникальных черт Go — интерфейсы реализуются неявно. Нет никакого ключевого слова implements или других явных объявлений. Если тип имеет все методы, определённые в интерфейсе, он автоматически реализует этот интерфейс.

Давайте посмотрим на конкретный пример:

package main

import "fmt"

type I interface {
	M()
}

type T struct {
	S string
}

// Метод M() означает, что тип T реализует интерфейс I
// Но мы не должны явно заявлять об этом
func (t T) M() {
	fmt.Println(t.S)
}

func main() {
	var i I = T{"hello"}
	i.M()
}

Результат: 

hello

Что здесь происходит? Мы объявили переменную i с типом интерфейса I, а затем присвоили ей значение T{"hello"}. Это работает потому, что структура T имеет метод M(), который отвечает требованиям интерфейса I. Go автоматически определил, что тип T реализует интерфейс I, и позволил выполнить это присваивание.

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

Значения интерфейсов

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

(значение, конкретный_тип)

Интерфейс хранит значение конкретного типа. Когда вы вызываете метод на значении интерфейса, Go вызывает соответствующий метод этого конкретного типа.

Рассмотрим практический пример:

package main

import (
	"fmt"
	"math"
)

type I interface {
	M()
}

type T struct {
	S string
}

func (t *T) M() {
	fmt.Println(t.S)
}

type F float64

func (f F) M() {
	fmt.Println(f)
}

func main() {
	var i I

	i = &T{"Hello"}
	describe(i)
	i.M()

	i = F(math.Pi)
	describe(i)
	i.M()
}

func describe(i I) {
	fmt.Printf("(%v, %T)\n", i, i)
}

Результат: 

(&{Hello}, *main.T)

Hello

(3.141592653589793, main.F)

3.141592653589793

Функция describe() показывает нам внутреннее состояние интерфейса: значение и его конкретный тип. Обратите внимание, что мы можем присвоить одной и той же переменной интерфейса значения разных типов. В первом случае это указатель на T, во втором — значение типа F. Go гибко обрабатывает оба случая.

Значения интерфейсов с nil-значениями

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

package main

import "fmt"

type I interface {
	M()
}

type T struct {
	S string
}

func (t *T) M() {
	if t == nil {
		fmt.Println("<nil>")
		return
	}
	fmt.Println(t.S)
}

func main() {
	var i I

	var t *T
	i = t
	describe(i)
	i.M()

	i = &T{"hello"}
	describe(i)
	i.M()
}

func describe(i I) {
	fmt.Printf("(%v, %T)\n", i, i)
}

Результат: 

(<nil>, *main.T)

<nil>

(&{hello}, *main.T)

hello

В Go это нормальная практика — методы часто пишут так, чтобы они корректно работали с nil-приёмниками. Это предохраняет от неожиданных паник.

Nil-интерфейсы

Совсем другое дело — интерфейс, который не содержит ни значения, ни конкретного типа. Это настоящий nil-интерфейс:

package main

import "fmt"

type I interface {
	M()
}

func main() {
	var i I
	describe(i)
	i.M()
}

func describe(i I) {
	fmt.Printf("(%v, %T)\n", i, i)
}

Результат: 

(<nil>, <nil>)

panic: runtime error: invalid memory address or nil pointer dereference

Когда вы вызываете метод на nil-интерфейсе, Go не знает, какой конкретный метод вызвать, потому что нет информации о типе. Результат — ошибка выполнения (panic). Всегда проверяйте интерфейсы на nil перед использованием!

Пустой интерфейс

Интерфейс, который не определяет ни одного метода, называется пустым интерфейсом:

interface{}

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

Классический пример — функция fmt.Print(), которая может принимать любое количество аргументов любых типов:

package main

import "fmt"

func main() {
	var i interface{}
	describe(i)

	i = 42
	describe(i)

	i = "hello"
	describe(i)
}

func describe(i interface{}) {
	fmt.Printf("(%v, %T)\n", i, i)
}

Результат: 

(<nil>, <nil>)

(42, int)

(hello, string)

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

Утверждения типов (Type Assertions)

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

Простая форма утверждения типа:

t := i.(T)

Это утверждает, что интерфейсное значение i содержит конкретный тип T, и присваивает базовое значение переменной t. Если i на самом деле не содержит T, программа перейдёт в состояние паники.

Более безопасный вариант — использовать двойное возвращаемое значение:

t, ok := i.(T)

Если i содержит T, то t получит базовое значение и ok будет true. Если нет, ok будет false, t получит нулевое значение типа T, и паника не произойдёт. Это похоже на чтение из map в Go.

Давайте посмотрим практический пример:

package main

import "fmt"

func main() {
	var i interface{} = "hello"

	// Опасный вариант
	s := i.(string)
	fmt.Println(s)

	// Безопасный вариант с проверкой
	s, ok := i.(string)
	fmt.Println(s, ok)

	// Проверяем тип, который не совпадает
	f, ok := i.(float64)
	fmt.Println(f, ok)

	// Это вызовет панику!
	f = i.(float64)
	fmt.Println(f)
}

Результат: 

hello

hello true

0 false

panic: interface conversion: interface {} is string, not float64

Совет: Всегда используйте безопасный вариант с двойным возвращаемым значением (t, ok := i.(T)), если вы не уверены в типе.

Переключение по типам (Type Switches)

Когда вам нужно проверить несколько типов одновременно, переключение по типам — это идеальный инструмент. Синтаксис выглядит почти как обычный switch, но вместо значений мы сравниваем типы:

switch v := i.(type) {
case T:
	// здесь v имеет тип T
case S:
	// здесь v имеет тип S
default:
	// нет совпадений; здесь v имеет тот же тип, что и i
}

Рассмотрим практический пример:

package main

import "fmt"

func do(i interface{}) {
	switch v := i.(type) {
	case int:
		fmt.Printf("Twice %v is %v\n", v, v*2)
	case string:
		fmt.Printf("%q is %v bytes long\n", v, len(v))
	default:
		fmt.Printf("I don't know about type %T!\n", v)
	}
}

func main() {
	do(21)
	do("hello")
	do(true)
}

Результат: 

Twice 21 is 42

"hello" is 5 bytes long

I don't know about type bool!

Type switch автоматически преобразует значение в корректный тип в каждом case. Это намного удобнее, чем писать цепочку утверждений типов.

Интерфейс Stringer

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

type Stringer interface {
	String() string
}

Любой тип, реализующий метод String(), может быть красиво отформатирован при печати. Пакет fmt и многие другие ищут этот интерфейс.

Давайте создадим полезный пример:

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
	return fmt.Sprintf("%v (%v years)", p.Name, p.Age)
}

func main() {
	a := Person{"Arthur Dent", 42}
	z := Person{"Zaphod Beeblebrox", 9001}
	fmt.Println(a, z)
}

Результат: 

Arthur Dent (42 years) Zaphod Beeblebrox (9001 years)

Без реализации String() вывод был бы просто {Arthur Dent 42} и {Zaphod Beeblebrox 9001}. Метод String() позволяет полностью контролировать, как выглядит ваш тип при печати.

Обработка ошибок с интерфейсами

В Go ошибки представлены встроенным интерфейсом:

type error interface {
	Error() string
}

Функции часто возвращают значение типа error. Нулевое значение error означает успех; ненулевое значение означает ошибку:

i, err := strconv.Atoi("42")
if err != nil {
	fmt.Printf("couldn't convert number: %v\n", err)
	return
}
fmt.Println("Converted integer:", i)

Давайте создадим собственный тип ошибки:

package main

import (
	"fmt"
	"time"
)

type MyError struct {
	When time.Time
	What string
}

func (e *MyError) Error() string {
	return fmt.Sprintf("at %v, %s", e.When, e.What)
}

func run() error {
	return &MyError{
		time.Now(),
		"it didn't work",
	}
}

func main() {
	if err := run(); err != nil {
		fmt.Println(err)
	}
}

Результат: 

at 2009-11-10 23:00:00 +0000 UTC m=+0.000000001, it didn't work

Реализуя интерфейс error, вы можете создавать собственные, информативные сообщения об ошибках.

Интерфейс Reader

Пакет io определяет интерфейс io.Reader, который представляет один конец потока данных:

func (T) Read(b []byte) (n int, err error)

Метод Read заполняет заданный байтовый срез данными и возвращает количество заполненных байтов и значение ошибки. Он возвращает io.EOF когда поток заканчивается.

Стандартная библиотека Go содержит много реализаций Reader: файлы, сетевые соединения, компрессоры, шифры и многое другое. Вот практический пример:

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	r := strings.NewReader("Hello, Reader!")

	b := make([]byte, 8)
	for {
		n, err := r.Read(b)
		fmt.Printf("n = %v err = %v b = %v\n", n, err, b)
		fmt.Printf("b[:n] = %q\n", b[:n])
		if err == io.EOF {
			break
		}
	}
}

Результат: 

n = 8 err = <nil> b = [72 101 108 108 111 44 32 82]

b[:n] = "Hello, R"

n = 6 err = <nil> b = [101 97 100 101 114 33 32 82]

b[:n] = "eader!"

n = 0 err = EOF b = [101 97 100 101 114 33 32 82]

b[:n] = ""

Интерфейс Reader — один из мощных инструментов Go, позволяющий писать код, который работает с любыми потоками данных единообразно.

Бонус: Различие между значениями и указателями

Очень важно понимать разницу между методами на значениях и методами на указателях. Давайте рассмотрим пример, где эта разница критична:

package main

import (
	"fmt"
	"math"
)

type Abser interface {
	Abs() float64
}

func main() {
	var a Abser
	f := MyFloat(-math.Sqrt2)
	v := Vertex{3, 4}

	a = f       // MyFloat реализует Abser (метод на значении)
	a = &v      // *Vertex реализует Abser (метод на указателе)
	
	// ОШИБКА: v имеет тип Vertex (не *Vertex) и НЕ реализует Abser
	// Метод Abs определён только на *Vertex
	a = v       // Это вызовет ошибку компиляции!

	fmt.Println(a.Abs())
}

type MyFloat float64

func (f MyFloat) Abs() float64 {
	if f < 0 {
		return float64(-f)
	}
	return float64(f)
}

type Vertex struct {
	X, Y float64
}

// Заметьте: приёмник (receiver) — это указатель *Vertex
func (v *Vertex) Abs() float64 {
	return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

Результат: 

cannot use v (variable of struct type Vertex) as Abser value in assignment:

Vertex does not implement Abser (method Abs has pointer receiver)

Важное правило: Если метод определён на указателе *T, то интерфейс можно реализовать только через указатель. Если метод определён на значении T, то интерфейс можно реализовать как через значение, так и через указатель (потому что Go автоматически дереферирует указатели при необходимости).

Заключение

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

В этой статье мы научились:

  • Базовым понятиям интерфейсов: как определять и использовать набор методов без явных объявлений реализации
  • Неявной реализации интерфейсов в Go, которая автоматически определяет, реализует ли тип данные методы
  • Структуре интерфейсов: как они хранят значение и его конкретный тип внутри своей внутренней реализации
  • Доступу к базовым типам через утверждения типов и как безопасно работать с ними
  • Использованию пустого интерфейса для работы со значениями неизвестного типа
  • Практическому применению интерфейсов в стандартной библиотеке Go и как они помогают писать чистый, гибкий код
  • Критической разнице между методами на значениях и указателях, как она влияет на реализацию интерфейсов
  • Методам работы с ошибками, потоками данных, и настройке форматирования выводов через стандартные интерфейсы

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

Next