Golang: інтерфейси, типи та методи на прикладі io.Copy()

Автор |  22/11/2025
 

Почав писати log collector з S3 до VictoriaLogs з використанням AWS GO SDK, і в коді достатньо багато використовуються різні Input/Ouput операції, бо треба отримати лог, розпарсити, записати дані.

В попередньому пості по інтерфейсам – Golang: interfaces – “магія” виклику методів через інтерфейси – вже трохи торкався теми того, що таке інтерфейси і як саме вони працюють, але там не було прикладів того, як саме вони використовуються.

Тож цього разу подивимось на дуже класний приклад використання інтерфейсів при роботі з функцією io.Copy() і ще раз трохи зазирнемо під капот внутрішньої реалізації інтерфейсів в Go.

Basic I/O example – os.Open(), os.Create() та io.Copy()

Напишемо простий код, який буде з одного файлу копіювати в інший:

package main

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

func main() {
  // open the source file for reading
  // returns pointer to os.File:
  // 'func os.Open(name string) (*os.File, error)':
  //
  // os.File represents an open file descriptor:
  // type File struct {
  //	// contains filtered or unexported fields
  // }
  sourceFile, err := os.Open("source.txt")
  if err != nil {
    panic(err)
  }
  // always close files when done
  defer sourceFile.Close()

  // create destination file for writing
  // 'func os.Create(name string) (*os.File, error)'
  destFile, err := os.Create("dest.txt")
  if err != nil {
    panic(err)
  }
  defer destFile.Close()

  // copy data from source to destination
  // io.Copy() pulls bytes from any Reader and pushes them into any Writer
  // 'func io.Copy(dst io.Writer, src io.Reader) (written int64, err error)'
  bytesWritten, err := io.Copy(destFile, sourceFile)
  if err != nil {
    panic(err)
  }

  fmt.Println("Copied bytes:", bytesWritten)
}

Тут ми:

  • з os.Open("source.txt") відкриваємо файл на читання
  • з os.Create("dest.txt") створюємо файл, в який будемо копіювати дані
  • і з io.Copy() копіюємо дані з “source.txt” до “dest.txt

Створюємо тестовий файл source.txt:

$ echo source > source.txt

Запускаємо нашу програму:

$ go run main.go
Copied bytes: 7

Що для нас важливо зараз – що виклики os.Open() і os.Create() повертають об’єкти типу *os.File struct – посилання на структуру.

А *os.File struct має набір методів, які далі використовуються для запуску процесу копіювання файлу з io.Copy().

Методи інтерфейсів на прикладі io.Copy()

Отже, до функції io.Copy() передаються destFile та sourceFile, які є об’єктами з типом *os.File:

...
  bytesWritten, err := io.Copy(destFile, sourceFile)
...

Функція io.Copy() приймає два аргументи – dst Writer та src Reader, які далі передає до функції copyBuffer():

// Copy copies from src to dst until either EOF is reached
func Copy(dst Writer, src Reader) (written int64, err error) {
  return copyBuffer(dst, src, nil)
}

copyBuffer() приймає такі самі аргументи та повертає такі самі дані:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
  ...
}

І для обох функцій – і Copy(), і copyBuffer() – аргументи є interface type:

А type Writer interface описує вимоги до типу, що може бути використаний через цей інтерфейс: такий тип повинен мати метод Write(), приймати аргумент з типом slice of bytes, і повертати значення int та err:

// Writer is the interface that wraps the basic Write method.
type Writer interface {
  Write(p []byte) (n int, err error)
}

Тобто в нашому коді:

...
    bytesWritten, err := io.Copy(destFile, sourceFile)
...

Об’єкт destFile повинен мати метод Write() – який в нього є, бо destFile – це *os.File struct, в якої є набір методів, в тому числі як раз Read() та Write():

$ go doc os.File
package os // import "os"

type File struct {
        // Has unexported fields.
}
    File represents an open file descriptor.

...

func (f *File) Read(b []byte) (n int, err error)
...
func (f *File) Write(b []byte) (n int, err error)

Де метод Write() приймає слайс байтів []byte:

func (f *File) Write(b []byte) (n int, err error) {
    ...
}

А отже, маючи об’єкт з типом *os.File – ми через відповідні інтерфейси можемо викликати *os.File.Write():

  • func Copy(dst Writer, ...) каже – “dst повинен мати метод Write([]byte) (int, error)
  • тип *os.File має метод Write() – а значить він задовольняє Writer interface

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

І коли ми пишемо і запускаємо io.Copy(destFile, ...) – під капотом Go під час компіляції програми:

  • перевіряє, який тип приймає io.Copy() – це interface type
  • аби задовільнити конкретно цей interface type – об’єкт (тип), який передається аргументом до io.Copy(), повинен мати метод Write()
  • Go перевіряє, чи є у переданого типу такий метод – чи є для об’єкту *os.File метод Write()

Далі – “магія”, описана в попередньому пості: ще раз глянемо на те, як працюють інтерфейси, як через них викликаються методи, і що саме знаходиться в аргументах io.Copy() та copyBuffer() при роботі програми.

Структури iface та itab

Коли ми передаємо обʼєкт (pointer на *os.File)  у параметр із типом інтерфейсу (dst Writer) – то Go формує дві внутрішні структури, які передаються до функцій як interface value.

Перша, type iface structiface, має два поля – tab і data:

type iface struct {
  // Pointer to the 'itab' (interface table)
  tab unsafe.Pointer
  // Pointer to the actual data (our *os.File struct)
  data unsafe.Pointer
}

Де:

  • tab unsafe.Pointer: pointer на другий тип type itab, який описаний в type ITab struct
    • раніше було type itab struct, зараз перенесли в Go ABI, про ABI в наступному пості
  • data unsafe.Pointer: pointer на наш об’єкт з типом os.File struct, який має метод Write()

Друга структура, ITab struct, має свої три поля:

type ITab struct {
   // pointer to the 'type Writer interface'
   Inter *InterfaceType
   // pointer to the 'type File struct'
   Type *Type
   // in our case if we have 1 method, thus '[N]uintptr' == [1]uintptr
   // and in the 'fun[0]' will be the address of the method 'Write()' of the 'os.File' struct
   Fun [1]uintptr // will have '[1]uintptr', and
}

Тут:

  • Inter *InterfaceType: pointer на опис type Writer interface
    • який інтерфейс треба задовольнити
  • Type: pointer на опис конкретного типу значення (у нашому випадку тип *os.File)
    • який тип ми передаємо
  • 'Fun[0]': буде посиланням на метод Write() структури os.File
    • ось адреси методів, які цей тип використовує для реалізації цього інтерфейсу

І коли ми в коді передаємо значення типу *os.File в параметр інтерфейсного типу (dst Writer) – то Go створює ці структури, і передає структура iface з полями tab і data до виклику io.Copy(), а потім далі – до copyBuffer():

io.Copy(iface):
- iface.tab => вказівник на структуру itab
- iface.data => вказівник на *os.File

В itab struct маємо таблицю методів, пов’язаних з цим інтерфейсом (або – які імплементують цей інтерфейс), а в полі fun структури itab знаходиться масив з pointers, де кожен елемент містить адресу функції, яка реалізує відповідний метод інтерфейсу для конкретного типу.

І у випадку з інтерфейсом Writer – це буде масив fun[0] зі значенням, наприклад, 0xc000014070, де за адресою 0xc000014070 буде розташований метод Write() типу *os.File.

І коли в copyBuffer(dst Writer) виконується виклик Write(), який описаний як:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    ...
      nw, ew := dst.Write(buf[0:nr])
...

То фактично це перетворюється на виклик itab.fun[0]:

copyBuffer(iface struct) => itab struct => fun[0] field => 0xc000014070 => os.File.Write()

Повертаючись до твердження “при виклику io.Copy(*os.File) – викликається copyBuffer(), якому першим аргументом передається структура iface” – давайте подивимось на аргументи, з якими ми працюємо.

Перевірка типів інтерфейсних значень в аргументах

Аби побачити все своїми очима – повторимо “хак” з попереднього поста – створимо власну структуру, яка аналогічна до iface, бо напряму до iface ми звернутись не можемо – але можемо прочитати її памʼять через unsafe.Pointer.

І на додачу створимо власну функцію myCopy(), яка буде мати в параметрах наші власні інтерфейси – аналогічно тому, як це зроблено для io.Copy().

Тобто – ми повністю повторюємо поведінку оригінального io.Copy(), але замість справжніх io.Reader та io.Writer використовуємо свої інтерфейси і власну структуру myIfaceStruct, аби подивитись, як Go зберігає інтерфейс у памʼяті:

  • створюємо два об’єкти sourceFile та destFile, які є pointers на *os.File
  • описуємо власну функцію myCopy(), яка в параметрах описує отримання інтерфейсних типів
  • наші інтерфейси myReaderInterface та myWriterInterface вимагають методів Read() та Write(), які є у sourceFile та destFile

Код виходить такий:

package main

import (
  "fmt"
  "io"
  "os"
  "unsafe"
)

type myIfaceStruct struct {
  tab  unsafe.Pointer
  data unsafe.Pointer
}

// Writer is the interface that wraps the basic Write method.
type myWriterInterface interface {
  // define Write method to satisfy the myWriterInterface interface
  Write(p []byte) (n int, err error)
}

// Reader is the interface that wraps the basic Read method.
type myReaderInterface interface {
  // define Read method to satisfy the myReaderInterface interface
  Read(p []byte) (n int, err error)
}

// accept any type which has Read and Write methods
func myCopy(src myReaderInterface, dst myWriterInterface) (int64, error) {
  // '&src' gives us the address of the interface variable 'src'
  // 'unsafe.Pointer(&src)' allows us to reinterpret that memory as a different type
  // the interface value occupies 16 bytes:
  //   - first 8 bytes: pointer to the method/type table ('tab')
  //   - next 8 bytes: pointer to the actual value ('data')
  // '(*myIfaceStruct)(...)' tells Go to treat those bytes as a 'myIfaceStruct'
  // '*(*myIfaceStruct)(...)' finally copies those bytes into the 'rawIface' variable
  rawIface := *(*myIfaceStruct)(unsafe.Pointer(&src))

  fmt.Println()

  // Print diagnostic messages
  //
  // we intentionally use '%p' modifier with a non-pointer value argument
  // this causes a formatting error, and 'fmt' prints a diagnostic message
  // that includes the full content of 'rawIface' (its type and both fields)
  fmt.Printf("'rawIface' data: %p\n", rawIface)
  // same idea for %s: &src is a *myReaderInterface, not a string
  // so fmt prints a diagnostic message showing the type and value
  fmt.Printf("'src' data: %s\n", &src)

  fmt.Println()

  // Print addresses from the 'iface' struct
  //
  // 'tab' field is a pointer to the interface's method table (the 'itab' struct)
  // this value is copied from the real interface value stored in 'src'
  fmt.Printf("Copy of the 'iface.tab': address stored inside 'rawIface.tab': %p\n", rawIface.tab)
  // 'data' field is a pointer to the underlying object (the *os.File struct)
  // also copied directly from the actual interface storage
  fmt.Printf("Copy of the 'iface.data': address stored inside 'rawIface.data': %p\n", rawIface.data)
  // print the address of the real underlying object (*os.File)
  // this should match the value stored in rawIface.data
  fmt.Printf("The 'src' (*os.File) actual object address: %p\n", src)

  fmt.Println()

  // Test sizes
  //
  // 'src' will have 16 bytes
  // because 'iface' has two fields: 'tab' and 'data'
  // they are pointers, each of 8 bytes
  fmt.Println("sizeof the 'src' (size of 'iface' with two pointers):", unsafe.Sizeof(src))
  // but pointer to the '*os.File' object size will be 8 bytes
  testSource, _ := os.Open("source.txt")
  fmt.Println("sizeof the 'testSource' (size of '*os.File' with one pointer):", unsafe.Sizeof(testSource))

  fmt.Println()

  // demonstrate "dynamic types"
  //
  // - Printf '%T' modifier will print the type of the variable
  // - Printf '%p' modifier will print the address pointed to by '&'
  fmt.Printf("'src' type: %T\n", src)
  // address of the the 'src'
  fmt.Printf("'src' address: %p\n", &src)

  fmt.Println()

  return io.Copy(dst, src)
}

func main() {
  // sourceFile is *os.File
  sourceFile, _ := os.Open("source.txt")
  defer sourceFile.Close()

  // destFile is *os.File
  destFile, _ := os.Create("dest.txt")
  defer destFile.Close()

  myCopy(sourceFile, destFile)
}

Запускаємо:

$ go run test-int.go

'rawIface' data: %!p(main.myIfaceStruct={0x4eee38 0xc000062030})
'src' data: %!s(*main.myReaderInterface=0xc000014070)

Copy of the 'iface.tab': address stored inside 'rawIface.tab': 0x4eee38
Copy of the 'iface.data': address stored inside 'rawIface.data': 0xc000062030
The 'src' (*os.File) actual object address: 0xc000062030

sizeof the 'src' (size of 'iface' with two pointers): 16
sizeof the 'testSource' (size of '*os.File' with one pointer): 8

'src' type: *os.File
'src' address: 0xc000014070

І розбираємо результат.

Перші два – зовсім “грязний хак”, випадково на нього натрапив: якщо до модифікатора в fmt.Printf() передати не той тип даних, який він очікує – він виводить повідомлення з деталями по помилці, де можемо побачити, що саме повністю передавалось (хоча як виявилось, під капотом просто викликається (reflect.TypeOf(p.arg).String())).

Перший блок:

  • rawIface є типом main.myIfaceStruct, яка містить два вказівники на адреси 0x4eee38 та 0xc000062030 – див. далі про зміст rawIface
  • src є поінтером на *main.myReaderInterface – структуру, яка знаходиться за адресою 0xc000014070

Далі – виводимо адреси, які зберігаються в полях iface (і які ми отримали через нашу власну структуру):

  • 'rawIface.tab': 0x4eee38 – тут адреса розміщення itab struct
  • 'rawIface.data': 0xc000062030 – тут адреса переданого через src об’єкту os.File
  • і ту саму адресу ми бачимо в наступному рядку – src є pointer на *os.File, з Printf(%p) отримуємо адресу, на яку src  вказує

Найбільш явний доказ того, що насправді myCopy() у (src myReaderInterface) працює з інтерфейсом, а не *os.File – це розмір:

  • з unsafe.Sizeof(src) отримуємо розмір самого інтерфейсного значення (iface), яке складається з двох pointers – tab і data, по 8 байт кожен
  • а testSource := os.Open("source.txt") має розмір 8 байт, бо це один поінтер

Інтерфейси Go та “dynamic type”

А далі ми бачимо те, що називають “динамічними типами”: в результатах unsafe.Sizeof(src)) ми побачили, що там 2 поінтери, тобто це 100% тип interface value з двома pointers.

Але в fmt.Printf("'src' type: %T\n", src) ми отримуємо тип *os.File – бо це pointer на структуру os.File:

$ go run test-int.go
...
'src' data: %!s(*main.myReaderInterface=0xc000014070)
...
'src' type: *os.File
'src' address: 0xc000014070

То чому не якийсь myReaderInterface{}?

Дивимось документацію по Variables:

The static type (or just type) of a variable is the type given in its declaration, the type provided in the new call or composite literal, or the type of an element of a structured variable. Variables of interface type also have a distinct dynamic type, which is the (non-interface) type of the value assigned to the variable at run time (unless the value is the predeclared identifier nil, which has no type). The dynamic type may vary during execution but values stored in interface variables are always assignable to the static type of the variable.

Отже, змінна має static type, коли:

  • змінна оголошується (var i int)
  • тип заданий під час присвоювання даних при виклику функцій (x := new(int))
  • при використанні composite literal (y := []string{"a", "b"})

Проте змінні інтерфейсного типу завжди мають фіксований статичний тип (сам інтерфейс) – але реальний об’єкт всередині неї має окремий dynamic type – це конкретний тип значення, присвоєного під час виконання.

І в нашому прикладі вище – iface.data як раз і є тою змінною, яка визначає dynamic type, і тому ми в результаті fmt.Printf("'src' type: %T\n", src) бачимо саме *os.File.

Додаємо до нашого коду ще трохи дебагу:

...
  // show the static type of the interface itself
  // - (*myReaderInterface)(nil) creates a nil pointer to the interface type
  // - reflect.TypeOf(...) gives the type of that pointer
  // - Elem() gives the type the pointer points to (the interface type)
  // this demonstrates that the static type is 'myReaderInterface'
  fmt.Println("static type of the 'myReaderInterface':", reflect.TypeOf((*myReaderInterface)(nil)).Elem())

  // show the dynamic type stored inside the interface variable 'src'
  // - 'src' is an interface value (16-byte iface: tab + data)
  // - reflect.TypeOf(src) reads the real type stored in iface.data
  // this prints the actual type, '*os.File' in our case
  //
  // and this is exactly the same information that 'fmt.Printf("%T", src)' prints:
  // both reflect.TypeOf(src) and %T reveal the dynamic type stored in the interface
  fmt.Printf("'src' dynamic type: %v\n", reflect.TypeOf(src))

  // show the type of the variable 'src' itself, not the value stored inside it
  // this is exactly what the myCopy() function "sees" when receiving its argument
  // - '&src' is a pointer to the interface variable
  // - reflect.TypeOf(&src) therefore reports: "*myReaderInterface"
  // this confirms that 'src' is an interface-typed variable, not a concrete value
  fmt.Println("'src' variable type: ", reflect.TypeOf(&src))
...

Результат:

$ go run test-int.go
...
static type of the 'myReaderInterface': main.myReaderInterface
'src' dynamic type: *os.File
'src' variable type:  *main.myReaderInterface

Тут ми:

  • в першій перевірці просто створюємо вказівник на інтерфейсний тип (але без створення самого об’єкту): результат є main.myReaderInterface
  • другий результат – “прочитай значення інтерфейсної змінної src, і скажи, який там тип” – саме тут ми бачимо, що в iface.data зберігається pointer на об’єкт типу – *os.File
  • третя перевірка – “сходи за адресою, де зберігається змінна src, і скажи який за цією адресою тип даних” – отримуємо pointer на *main.myReaderInterface

Використання інтерфейсів на прикладі io.Copy()

То що це все значить для нас?

А значить, що використовуючи інтерфейси, ми можемо передати будь-які значення (типи), які реалізують інтерфейс.

Якщо повернутись до нашого першого коду, то в io.Copy() першим параметром ми можемо передати будь-який тип, який має метод Write([]byte) (int, error), а в другий – аналогічно, тільки Read(), бо під капотом Copy() викликає copyBuffer(), а той просто створює буфер розміром в 32 кілобайти, чей який “переливає” з одного “каналу” в інший:

func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
        ...
  if buf == nil {
    size := 32 * 1024
                ...
    buf = make([]byte, size)
  }
  for {
    nr, er := src.Read(buf)
    if nr > 0 {
      nw, ew := dst.Write(buf[0:nr])
        ...

А значить – ми можемо у Writer передати os.Stdout, тобто просто вивести на консоль:

...

func main() {
  // open the source file for reading
  // returs pointer to os.File:
  // 'func os.Open(name string) (*os.File, error)':
  //
  // os.File represents an open file descriptor:
  // type File struct {
  //	// contains filtered or unexported fields
  // }
  sourceFile, err := os.Open("source.txt")
  if err != nil {
    panic(err)
  }
  // always close files when done
  defer sourceFile.Close()

  // printto console instead
  // actualy, os.Stdout is also *os.File
  // thus it also has Write() method
  bytesWritten, err := io.Copy(os.Stdout, sourceFile)
  if err != nil {
    panic(err)
  }

  fmt.Println("Copied bytes:", bytesWritten)
}

Результат:

$ go run main.go
source
Copied bytes: 7

Або можемо створити власний буфер в пам’яті, і писати в нього, бо bytes.Buffer теж має метод Write():

$ go doc bytes.Buffer | grep "Write("
func (b *Buffer) Write(p []byte) (n int, err error)

А тому ми можемо передати його до io.Copy():

...
  buf := &bytes.Buffer{}
  io.Copy(buf, sourceFile)
  fmt.Println("Buffer content:", buf.String())
...

Результат:

$ go run main.go
Buffer content: source

Або більш цікавий приклад – з викликом одного інтерфейсу через інший.

Такий код:

...
  // create a pointer to bytes.Buffer (it implements io.Writer)
  buf := &bytes.Buffer{}

  // var resp *http.Response
  resp, _ := http.Get("https://example.com")
  defer resp.Body.Close()

  fmt.Printf("resp.Body %T\n", resp.Body)

  io.Copy(buf, resp.Body)

  // print the buffer content
  fmt.Println("Buffer content:", buf.String())
...

Результат:

$ go run main.go
resp.Body type: *http.http2gzipReader
Buffer content: <!doctype html><html lang="en"><head><title>Example Domain</title>
...

Тут:

  • http.Get() повернув *http.Response, тобто структуру Response
  • у структурі Response є поле Body з типом io.ReadCloser interface
  • інтерфейс io.ReadCloser визначає два методи – Read() і Close()
  • сервер надіслав відповідь у gzip, тому HTTP-клієнт Go автоматично обгорнув Body у декомпресор – тип *http.http2gzipReader
  • тип *http.http2gzipReader реалізує метод Read(), тому він задовольняє інтерфейс io.Reader
  • також у нього є метод Close(), тому він задовольняє і io.ReadCloser
  • оскільки resp.Body має динамічний тип *http.http2gzipReader, то виклик io.Copy(buf, resp.Body) фактично викликає (*http.http2gzipReader).Read()
  • тому io.Copy() може прийняти resp.Body як io.Reader і прочитати через gzip-декодер

Власне, мабуть, на цьому і все.

Далі треба вчитись писати код з інтерфейсами самому.