[Это сообщение в блоге написано Сергеем Матюкевичем. Другие части серии: Часть 1 | Часть 2 | Часть 3 ]
Знаете ли вы, что именно происходит во время выполнения Go, когда вы используете переменную через ссылку на интерфейс? Это не тривиальный вопрос, потому что в Go тип, реализующий интерфейс, вообще не содержит ссылок на этот интерфейс. Тем не менее, мы можем попытаться ответить на него, используя наши знания компилятора Go, который обсуждался в предыдущем посте.
Итак, давайте углубимся в компилятор Go: создадим простую программу Go и посмотрим внутреннюю работу приведения типов в Go. Используя его в качестве примера, я объясню, как генерируется и используется дерево узлов. Таким образом, вы можете в дальнейшем применить эти знания к функциям других компиляторов Go.
Прежде чем ты начнешь
Для проведения эксперимента нам нужно будет работать напрямую с компилятором Go (а не с инструментом Go). Вы можете получить к нему доступ с помощью команды:
go tool 6g test.go
Он скомпилирует исходный файл test.go и создаст объектный файл. Здесь 6g — это имя компилятора на моей машине с архитектурой AMD64. Обратите внимание, что вы должны использовать разные компиляторы для разных архитектур. Когда мы работаем напрямую с компилятором, мы можем использовать несколько удобных аргументов командной строки (более подробно здесь ). Для целей этого эксперимента нам понадобится флаг -W, который будет печатать макет дерева узлов.
Создание простой программы Go
Прежде всего, мы собираемся создать пример программы Go. Что-то вроде этого:
package main type I interface { DoSomeWork() } type T struct { a int } func (t *T) DoSomeWork() { } func main() { t := &T{} i := I(t) print(i) }
Действительно просто, не правда ли? Единственное, что может показаться ненужным, это 17-я строка, где мы печатаем переменную i . Тем не менее, без него я останусь неиспользованным и программа не будет скомпилирована. Следующим шагом является компиляция нашей программы с помощью ключа -W:
go tool 6g -W test.go
После этого вы увидите выходные данные, которые содержат деревья узлов для каждого метода, определенного в программе. В нашем случае это основной метод и метод init . Метод init здесь, потому что он неявно определен для всех программ, но на самом деле нас это сейчас не волнует. Для каждого метода компилятор печатает две версии дерева узлов. Первый — это исходное дерево узлов, которое мы получаем после анализа исходного файла. Вторая версия — это версия, которую мы получаем после проверки типа и применения всех необходимых изменений.
Понимание дерева узлов основного метода
Давайте подробнее рассмотрим исходную версию дерева узлов из метода main и попытаемся понять, что именно происходит.
DCL l(15) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T AS l(15) colas(1) tc(1) . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T . PTRLIT l(15) esc(no) ld(1) tc(1) PTR64-*main.T . . STRUCTLIT l(15) tc(1) main.T . . . TYPE <S> l(15) tc(1) implicit(1) type=PTR64-*main.T PTR64-*main.T DCL l(16) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I AS l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T . NAME-main.t u(1) a(1) g(1) l(15) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) PTR64-*main.T AS l(16) colas(1) tc(1) . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I . CONVIFACE l(16) tc(1) main.I . . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T VARKILL l(16) tc(1) . NAME-main.autotmp_0000 u(1) a(1) l(16) x(0+0) class(PAUTO) esc(N) tc(1) used(1) PTR64-*main.T PRINT l(17) tc(1) PRINT-list . NAME-main.i u(1) a(1) g(2) l(16) x(0+0) class(PAUTO) f(1) ld(1) tc(1) used(1) main.I
В объяснении ниже я буду использовать сокращенную версию, из которой я удалил все ненужные детали. Первый узел довольно прост:
DCL l(15) . NAME-main.t l(15) PTR64-*main.T
Первый узел является узлом объявления. l (15) говорит нам, что этот узел определен в строке 15. Узел объявления ссылается на узел имени, который представляет переменную main.t. Эта переменная определена в основном пакете и на самом деле является 64-битным указателем на тип main.T. Вы можете взглянуть на строку 15 и легко понять, какое объявление там представлено. Следующий немного сложнее.
AS l(15) . NAME-main.t l(15) PTR64-*main.T . PTRLIT l(15) PTR64-*main.T . . STRUCTLIT l(15) main.T . . . TYPE l(15) type=PTR64-*main.T PTR64-*main.T
Корневой узел является узлом назначения. Его первый дочерний элемент — это узел имени, представляющий переменную main.t. Второй дочерний элемент — это узел, который мы назначаем main.t — литеральный узел указателя (&). У него есть дочерний литерал структуры, который, в свою очередь, указывает на узел типа, представляющий фактический тип ( main.T ). Следующий узел — это другое объявление. На этот раз это объявление переменной main.i, которая принадлежит типу main.I.
DCL l(16) . NAME-main.i l(16) main.I
Затем компилятор создает другую переменную autotmp_0000 и назначает ей переменную main.t.
AS l(16) tc(1) . NAME-main.autotmp_0000 l(16) PTR64-*main.T . NAME-main.t l(15) PTR64-*main.T
Наконец, мы подошли к узлам, в которых мы на самом деле пересекались.
AS l(16) . NAME-main.i l(16)main.I . CONVIFACE l(16) main.I . . NAME-main.autotmp_0000 PTR64-*main.T
Здесь мы можем видеть , что компилятор назначил специальный узел , называемый CONVIFACE к main.i переменной. Но это не дает нам много информации о том, что происходит под капотом. Чтобы выяснить, что происходит, нам нужно заглянуть в дерево узлов основного метода после того, как все модификации дерева узлов были применены (вы можете найти эту информацию в разделе «after walk main» вашего вывода).
Как компилятор переводит узел присваивания
Ниже вы можете увидеть, как компилятор переводит наш узел присваивания:
AS-init . AS l(16) . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 . IF l(16) . IF-test . . EQ l(16) bool . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . LITERAL-nil I(16) PTR64-*uint8 . IF-body . . AS l(16) . . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . . CALLFUNC l(16) PTR64-*byte . . . . NAME-runtime.typ2Itab l(2) FUNC-funcSTRUCT-(FIELD- . . . . . NAME-runtime.typ·2 l(2) PTR64-*byte, FIELD- . . . . . NAME-runtime.typ2·3 l(2) PTR64-*byte PTR64-*byte, FIELD- . . . . . NAME-runtime.cache·4 l(2) PTR64-*PTR64-*byte PTR64-*PTR64-*byte) PTR64-*byte . . . CALLFUNC-list . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ·2 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type.*"".T l(11) uint8 . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.typ2·3 G0 PTR64-*byte . . . . . ADDR l(16) PTR64-*uint8 . . . . . . NAME-type."".I l(16) uint8 . . . . AS l(16) . . . . . INDREG-SP l(16) runtime.cache·4 G0 PTR64-*PTR64-*byte . . . . . ADDR l(16) PTR64-*PTR64-*uint8 . . . . . . NAME-go.itab.*"".T."".I l(16) PTR64-*uint8 AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T
Как видно из выходных данных, компилятор сначала добавляет список узлов инициализации ( AS-init ) к узлу назначения. Внутри АС-инициализации узла, он создает новую переменную, main.autotmp_0003 и присваивает значение go.itab. * «». Т «». Я переменной к нему. После этого он проверяет, равна ли эта переменная нулю. Если переменная равна nil, компилятор вызывает функцию runtime.typ2Itab и передает ей следующее: указатель на тип main.T , указатель на тип интерфейса main.I и указатель на go.itab. * «.T.» «. Я переменная. Из этого кодасовершенно очевидно, что эта переменная предназначена для кэширования результата преобразования типа изmain.T к main.I .
Внутри метода getitab
Следующий логический шаг — найти runtime.typ2Itab . Ниже приведен список этой функции:
func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab { tab := getitab(inter, t, false) atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab)) return tab }
Совершенно очевидно, что фактическая работа выполняется внутри метода getitab , потому что вторая строка просто хранит созданную переменную табуляции в кэше. Итак, давайте посмотрим внутрь getitab . Поскольку он довольно большой, я скопировал только самую ценную часть.
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys)) m.inter = interm._type = typ ni := len(inter.mhdr) nt := len(x.mhdr) j := 0 for k := 0; k < ni; k++ { i := &inter.mhdr[k] iname := i.name ipkgpath := i.pkgpath itype := i._type for ; j < nt; j++ { t := &x.mhdr[j] if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath { if m != nil { *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn } } } }
Сначала мы выделяем память для результата:
(*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize, 0, &memstats.other_sys))
Почему мы должны выделять память в Go и почему это делается таким странным образом? Чтобы ответить на этот вопрос, нам нужно взглянуть на определение структуры itab .
type itab struct { inter *interfacetype _type *_type link *itab bad int32 unused int32 fun [1]uintptr // variable sized }
Последнее свойство, fun , определяется как массив из одного элемента, но на самом деле оно имеет переменный размер. Позже мы увидим, что это свойство содержит массив указателей на методы, определенные в определенном типе. Эти методы соответствуют методам в типе интерфейса. Авторы Go используют динамическое выделение памяти для этого свойства (да, такие вещи возможны, когда вы используете небезопасный пакет). Объем выделяемой памяти рассчитывается путем добавления размера самой структуры к числу методов в интерфейсе, умноженному на размер указателя.
unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*ptrSize
Далее вы можете увидеть два вложенных цикла. Сначала мы перебираем все методы интерфейса. Для каждого метода в интерфейсе мы пытаемся найти соответствующий метод в определенном типе (методы хранятся в коллекции mhdr ). Процесс проверки того, равны ли два метода, совершенно очевиден.
if t.mtyp == itype && t.name == iname && t.pkgpath == ipkgpath
Если мы находим совпадение, мы сохраняем указатель на метод в свойстве fun результата:
*(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*ptrSize)) = t.ifn
Небольшое замечание о производительности: поскольку методы сортируются в алфавитном порядке для интерфейса и заданных определений типов, этот вложенный цикл может повторяться O (n + m) раз вместо O (n * m) раз, где n и m соответствуют числу методов. Наконец, вы помните последнюю часть задания?
AS l(16) . NAME-main.i l(16) main.I . EFACE l(16) main.I . . NAME-main.autotmp_0003 l(16) PTR64-*uint8 . . NAME-main.autotmp_0000 l(16) PTR64-*main.T
Здесь мы относим EFACE узел к переменной main.i. Этот узел ( EFACE ) содержит ссылки на переменную main.autotmp_0003 — указатель на структуру itab, возвращенную методом runtime.typ2Itab, — и на переменную autotmp_0000 , содержащую то же значение, что и переменная main.t. Это все, что нам нужно для вызова методов по интерфейсным ссылкам. Итак, переменная main.i содержит экземпляр структуры iface, определенной в пакете времени выполнения:
type iface struct { tab *itab data unsafe.Pointer }
Что дальше?
Я понимаю, что пока что рассмотрел только очень небольшую часть компилятора Go и среды выполнения Go. Есть еще много интересных вещей для обсуждения, таких как объектные файлы, компоновщик, перемещения и т. Д. — они будут рассмотрены в следующих публикациях в блоге.
Читать все части серии: Часть 1 | Часть 2 | Часть 3
Об авторе: Сергей Матюкевич — облачный инженер и разработчик Go в Altoros . С более чем 6-летним опытом в разработке программного обеспечения, он является экспертом в области облачной автоматизации и проектирования архитектур для сложных облачных систем. Активный участник сообщества Go, Сергей часто участвует в проектах с открытым исходным кодом, таких как Ubuntu и Juju Charms.