Идите интерфейсы: предпочитая композицию по сравнению с наследством (с чертой здравого смысла)

Идите интерфейсы: предпочитая композицию по сравнению с наследством (с чертой здравого смысла)

13 августа 2025 г.

Имеясь с такого языка, как C# или JavaScript, интерфейсы в Go могут чувствовать себя жестокой шуткой. Они не то, что вы ожидаете, и они не играют по правилам, к которым вы привыкли. Этот пост является вашим руководством по выживанию, чтобы перейти.

The chimera is a mythical creature composed of parts from different animals - a lion, a goat, and a serpent - fused into a single being.

Интерфейсы - старый путь

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

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

public interface ILogger
{
  void Log(string message);
  void Warn(string message);
  void Error(string message);
}

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

public class OrderProcessor
{
  private readonly ILogger _logger;

  public OrderProcessor(ILogger logger)
  {
    _logger = logger;
  }

  public void ProcessOrder(string orderId)
  {
    _logger.Log($"Processing order {orderId}...");
    
    if (string.IsNullOrEmpty(orderId))
    {
      _logger.Warn("Order ID is empty.");
      return;
    }

    if (!ChargePayment(orderId))
    {
      _logger.Error($"Failed to charge payment for order {orderId}.");
    }
    else
    {
      _logger.Log($"Order {orderId} processed successfully.");
    }
  }
}

Таким образом, мы делаем что -то, и время от времени мы называем методы на регистраторе. Может быть, во время развития мы войдем в консоли, но позже мы хотим войти в файл. Таким образом, мы создаем экрановый класс и класс FileLogger, которые реализуют интерфейс ilogger:

public class ScreenLogger : ILogger
{
  public void Log(string message) => Console.WriteLine($"[LOG] {message}");
  public void Warn(string message) => Console.WriteLine($"[WARN] {message}");
  public void Error(string message) => Console.WriteLine($"[ERROR] {message}");
}

public class FileLogger : ILogger
{
  private readonly string _filePath;

  public FileLogger(string filePath)
  {
    _filePath = filePath;
  }

  public void Log(string message) => File.AppendAllText(_filePath, $"[LOG] {message}\n");
  public void Warn(string message) => File.AppendAllText(_filePath, $"[WARN] {message}\n");
  public void Error(string message) => File.AppendAllText(_filePath, $"[ERROR] {message}\n");
}

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

Таким образом, мы можем пройтиScreenLoggerилиFileLoggerвOrderProcessor, и это будет работать плавно:

var logger: ILogger;
if (production)
{
  logger = new FileLogger("logs.txt");
}
else
{
  logger = new ScreenLogger();
}

var processor = new OrderProcessor(logger);
processor.ProcessOrder("12345");

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

Интерфейсы в ходе

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

package main

import (
	"fmt"
	"os"
)

// 1. Logger interface definition
type Logger interface {
	Log(message string)
	Warn(message string)
	Error(message string)
}


// 2. ScreenLogger implementation:
type ScreenLogger struct{}

func (ScreenLogger) Log(message string) {
	fmt.Printf("[LOG] %s\n", message)
}

func (ScreenLogger) Warn(message string) {
	fmt.Printf("[WARN] %s\n", message)
}

func (ScreenLogger) Error(message string) {
	fmt.Printf("[ERROR] %s\n", message)
}
// At this point ScreenLogger has Log, Warn, and Error methods.
// This means it satisfies the Logger interface!


// 3. FileLogger implementation:
type FileLogger struct {
	filePath string
}

// Creates a new FileLogger instance with the specified file path.
// This is similar to the constructor in C#.
func NewFileLogger(path string) *FileLogger {
	return &FileLogger{filePath: path}
}

// Helper method to append messages to a file.
// Not part of the Logger interface, but FileLogger needs it.
// It starts with lowercase so it's also private to this package.
func (f *FileLogger) appendToFile(prefix, message string) {
	file, err := os.OpenFile(f.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		fmt.Printf("Error opening log file: %v\n", err)
		return
	}
	defer file.Close()

	fmt.Fprintf(file, "%s %s\n", prefix, message)
}

func (f *FileLogger) Log(message string) {
	f.appendToFile("[LOG]", message)
}

func (f *FileLogger) Warn(message string) {
	f.appendToFile("[WARN]", message)
}

func (f *FileLogger) Error(message string) {
	f.appendToFile("[ERROR]", message)
}
// At this point FileLogger also has Log, Warn, and Error methods.
// This means it satisfies the Logger interface too!


// Business logic that depends on Logger
func ProcessOrder(orderID string, logger Logger) {
	logger.Log("Processing order " + orderID + "...")

	if orderID == "" {
		logger.Warn("Order ID is empty.")
		return
	}
	if orderID[len(orderID)-1] == '0' {
		logger.Error("Failed to charge payment for order " + orderID)
	} else {
		logger.Log("Order " + orderID + " processed successfully.")
	}
}

func main() {
	var logger Logger
	if production := false; production {
		logger = &FileLogger{"logs.txt"}
	} else {
		logger = ScreenLogger{}
	}

  ProcessOrder("12345", logger)

Этот код GO делает то же самое, что и пример C#, но обратите внимание, как нам не нужно явно заявлять, что этоScreenLoggerиFileLoggerреализоватьLoggerинтерфейс. Они просто делают, и компилятор Go проверяет, что у них есть необходимые методы.

Это называетсянеявная реализацияПолем Если тип имеет методы, которые требуют интерфейса, он удовлетворяет этот интерфейс. Нет необходимости в ключевом словом, какimplementsилиextendsПолем Это называетсяутка набираетВ некоторых кругах, где тип определяется его поведением, а не явным объявлением. Как будто Го, это говорит: «Если он ходит как утка и кряки, как утка, это утка».

Таким образом, ключевое отличие в философии:

  • Номинальная набор(C#): «Вы - регистратор, если вы говорите, что есть (и компилятор соглашается)».
  • Утка набирает(Go): «Вы - регистратор, если вы идете, и кряков, как один, вам не нужно ничего говорить».

Как на самом деле используются интерфейсы

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

Аio.Writerиio.ReaderШаблон

Аio.Writerиio.ReaderИнтерфейсы являются основополагающими в ходе. Они позволяют вам писать данные в различные направления (например, файлы, сетевые подключения или буферы), не заботясь о базовой реализации. Например:

package main

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

func main() {
	var w io.Writer // io.Writer is a Go interface with one method: Write(p []byte) (n int, err error)
	w = os.Stdout  // os.Stdout is a variable of type *os.File
	// *os.File has a Write([]byte) (int, error) method, so it implicitly satisfies io.Writer
	fmt.Fprintln(w, "Hello, World!") // Since w is os.Stdout, the text goes to your terminal

	var r io.Reader // io.Reader is a Go interface with one method: Read(p []byte) (n int, err error)
	r = strings.NewReader("Hello, Reader!") // strings.NewReader returns a *strings.Reader, which has a Read([]byte) (int, error) method
	io.Copy(os.Stdout, r) // io.Copy(dst Writer, src Reader) reads from src and writes to dst until EOF or error
}

Итак, потому что любой тип с правильным набором метода подходит для интерфейса, такие функции, какio.CopyМожет работать над любой читаемой/считанной вещью - файлами, сетевыми розетками, буферами и т. Д. - без того, чтобы они были в том же дереве наследования.

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

Композиция посредством встраивания интерфейсов

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

Новый интерфейс наследует все методы встроенных интерфейсов. Это как строить большие интерфейсы из более мелких, сфокусированных.

package main

import "fmt"

// Two small, focused interfaces
type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

// New interface composed from the two
type ReadWriter interface {
	Reader
	Writer
}

// A type that implements both Read and Write automatically implements ReadWriter
type MyBuffer struct {
	data []byte
}

func (b *MyBuffer) Read(p []byte) (int, error) {
	n := copy(p, b.data)
	return n, nil
}

func (b *MyBuffer) Write(p []byte) (int, error) {
	b.data = append(b.data, p...)
	return len(p), nil
}

func main() {
	var rw ReadWriter = &MyBuffer{}
	rw.Write([]byte("hello"))
	buf := make([]byte, 5)
	rw.Read(buf)
	fmt.Println(string(buf))
}

Таким образом, вы можете разбить функциональность на небольшие интерфейсы (ReaderВWriter), затем объедините их в более богатые (ReadWriter), скомпозиция, нетнаследованиеПолем Это мощный способ создания гибких и многоразовых компонентов без жесткости классовых иерархий.

Интерфейсы с одним методом

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

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

package main

import (
	"fmt"
)

// From fmt package:
// type Stringer interface {
//     String() string
// }

// Define a new named type based on int
type MyInt int

// Add the method required by Stringer
func (m MyInt) String() string {
	return fmt.Sprintf("MyInt value: %d", m)
}

func main() {
	var s fmt.Stringer

	// Works because MyInt has a String() method, therefore it satisfies fmt.Stringer
	s = MyInt(42)
	fmt.Println(s) // Prints: MyInt value: 42
}

Ключевые моменты здесь:

  1. Мы не трогалиfmt.Stringer(Это в стандартной библиотеке).
  2. Мы не изменилиint- Мы только что определили свои собственныеMyIntТип на основеintПолем
  3. Добавив один метод,MyIntтеперь удовлетворяетfmt.StringerПолем
  4. Любая функция, ожидаяfmt.Stringerтеперь может принятьMyIntПолем

Одним из преимуществ этого подхода являетсяloose couplingПолем С наследством детского класса орудийвсеМетоды родительского класса, что означает, что если что -то изменится в родителе, это может сломать ребенка. В Go вы начинаете с нулевых зависимостей и только добавляете то, что вам нужно.

Аinterface{}(Пустой интерфейс) Ад

interface{}IS -пустой интерфейс GO - интерфейс с нулевыми методами. Он соответствует любому типу, потому что каждый тип реализует нулевые или более методы, поэтому каждый тип удовлетворяет пустую интерфейсу. Это какObjectв Java илиanyв типографии.

Итак, вы можете сделать это:

var x interface{}
x = 42              // int
x = "hello"         // string
x = []int{1, 2, 3}  // slice of int

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

Это часто называют какИнтерфейс {} адПолем Вы получите код, который выглядит так:

func PrintAnything(v interface{}) {
	switch v := v.(type) {
	case int:
		fmt.Println("int:", v)
	case string:
		fmt.Println("string:", v)
	case []int:
		fmt.Println("[]int:", v)
	default:
		fmt.Println("unknown type")
	}
}

func main() {
  PrintAnything(42)
  PrintAnything("hello")
  PrintAnything([]int{1, 2, 3})
}

Итак, если вы должны использоватьinterface{}, сохраняйте использование локализованным и минимальным.

Плохой и некрасивый

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

Нет явных "орудий"

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

Нет простого способа объявить дополнительные методы интерфейса

В ходе интерфейсы выполняются только в том случае, если тип реализует все объявленные методы. Нет встроенной поддержки для дополнительных методов-вы не можете сказать «тип может реализовать метод X, но это не требуется».

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

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

Например:

type Logger interface {
    Log(msg string)
}

type WarnLogger interface {
    Logger
    Warn(msg string)
}

func UseLogger(l Logger) {
    l.Log("info")

    if wl, ok := l.(WarnLogger); ok {
        wl.Warn("warning")
    }
}

Здесь, Warn () является необязательным - вы проверяете во время выполнения, если журнал поддерживает его. Эта модель является многословным и повторяющимся во многих местах, где желательно дополнительное поведение.

Обработка ошибок и делегирование могут быть повторяющимися

При делегировании вызовов в встроенные интерфейсы или составленные структуры вы часто пишете код шаблона, который:

  • Проверяет ошибки.
  • Проходит методы вызовов через.
  • Обертывание или аннотирует ошибки.

Поскольку GO не имеет наследства или автоматического делегирования, как некоторые языки ООП, вы пишете это вручную.

Например:

type MyWriter struct {
    w io.Writer
}

func (m *MyWriter) Write(p []byte) (int, error) {
    n, err := m.w.Write(p)
    if err != nil {
        return n, fmt.Errorf("write failed: %w", err)
    }
    return n, nil
}

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

Отражение необходимо для некоторых динамических целей

В ходе, если вы хотите проверять типы во время выполнения или динамически вызовать методы, вам часто приходится использоватьreflectупаковка.

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

func PrintFields(v interface{}) {
    val := reflect.ValueOf(v)
    typ := val.Type()

    if val.Kind() != reflect.Struct {
        fmt.Println("Not a struct")
        return
    }

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)
        fmt.Printf("%s = %v\n", typ.Field(i).Name, field.Interface())
    }
}

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

Загрязнение интерфейса

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

Как с этим работать (лучшие практики)

Чтобы избежать подводных камней интерфейсов GO, вот несколько лучших практик:

  1. Используйте небольшие интерфейсы: Предпочитают небольшие, сфокусированные интерфейсы над большими, монолитными. Это облегчает реализацию, понимание и повторное использование. Например, используйтеio.Writerвместо большогоBigAbstractThingинтерфейс.
  2. Принять интерфейсы, возвращать структуры: При проектировании функций принимайте интерфейсы в виде параметров, но возвращайте конкретные типы. Это позволяет вам использовать гибкость интерфейса, одновременно скрывая детали реализации.
  3. Сделать реализации интерфейса явными: Использоватьvar _ Interface = (*Type)(nil)Чтобы узнать, что тип реализует интерфейс. Это может помочь поймать ошибки во время компиляции и улучшить читаемость кода.
  4. Избегатьinterface{}Если это необходимо: экономно используйте пустой интерфейс. Если вы часто используете его, подумайте, можете ли вы определить более конкретный интерфейс. Или использовать дженерики.
  5. Тщательно используйте утверждения типа: Если вам нужно использовать утверждения типа, сделайте это контролируемым образом. Предпочитаем переключатели типа в течение нескольких утверждений и четко документируйте ожидаемые типы.
  6. Держите интерфейсы локализованы: Если интерфейс используется только в одном пакете, держите его там. Не экспортируйте его, если это необходимо. Это уменьшает загрязнение интерфейса и облегчает навигацию ваш код.

Следуя этой практике может помочь вам написать более чистый, более обслуживающий код GO, который использует мощность интерфейсов, не попадая в общие ловушки. Это все еще не так просто, как на некоторых других языках, но с небольшим здравым смыслом и опытом, вы можете заставить его работать на вас. Удачи, тебе понадобится!

Заключительные мысли

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

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

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

Хорошо, что вам это не нужно. Плохо, что вы должны понимать их, чтобы использовать эффективно. Так что примите странность, изучите узоры и помните, что интерфейсы GO - это просто еще один инструмент в вашем наборе инструментов. Они могут быть не тем, к чему вы привыкли, но с некоторой практикой вы будете знать, как их терпеть.


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE