Что такое типовой вывод? Что это такое и как это работает

Что такое типовой вывод? Что это такое и как это работает

7 июля 2025 г.

Это блог версия моего выступления по типовым выводу на Gophercon 2023 в Сан -Диего, слегка расширенная и отредактированная для ясности.

Что такое типовой вывод?

Википедия определяет тип вывода следующим образом:

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

Ключевая фраза здесь - «автоматически вывести… тип выражения». С самого начала поддерживает основную форму вывода типа:

const x = expr  // the type of x is the type of expr
var x = expr
x := expr

В этих объявлениях не указано явных типов, и, следовательно, типы постоянных и переменныхxслева от=и:=являются типами соответствующих выражений инициализации справа. Мы говорим, что типывыводитсяиз (типов) их выражений инициализации. С внедрением дженериков в GO 1.18, способности к выводу типа GO были значительно расширены.

Почему вывод типа?

В негенерическом коде GO эффект от ухода из-за того, что типы отдали наиболее выражены в короткой переменной объявлении. Такое объявление сочетает в себе вывод типа и немного синтаксического сахара - способность уйти отvarКлючевое слово - один очень компактный оператор. Рассмотрим следующее объявление переменной карты:

var m map[string]int = map[string]int{}

против

m := map[string]int{}

Пропуская тип слева от:=Удаляет повторение и в то же время увеличивает читабельность.

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

Рассмотрите возможность использования следующих двух функций из новогосрезы пакет:

package slices
func BinarySearch[S ~[]E, E cmp.Ordered](x S, target E) (int, bool)
func Sort[S ~[]E, E cmp.Ordered](x S)

Без вывода типа, вызовыBinarySearchиSortТребуется явные аргументы типа:

type List []int
var list List
slices.Sort[List, int](list)
index, found := slices.BinarySearch[List, int](list, 42)

Мы бы не повторили[List, int]с каждым таким общим вызовом функции. С выводом типа код упрощает:

type List []int
var list List
slices.Sort(list)
index, found := slices.BinarySearch(list, 42)

Это и чище, и компактно. На самом деле он выглядит точно так же, как негенерический код, и вывод типа делает это возможным.

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

Вывод типа - это форма сопоставления схем типа

Выводы сравнивают шаблоны типа, где шаблон типа - это тип, содержащий параметры типа. По причинам, которые станут очевидными в некоторых случаях, параметры типа иногда также вызываютсяТип переменныхПолем Сопоставление шаблонов типа позволяет нам вывести типы, которые должны перейти в эти переменные типа. Давайте рассмотрим короткий пример:

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

type List []int
var list List
slices.Sort(list)

АSortфункциональный вызов проходитlistпеременная как аргумент функции для параметраxизslices.SortПолем Поэтому типlist, что естьList, должен соответствовать типуx, который является параметром типаSПолем ЕслиSимеет типList, это задание становится действительным. На самом делеПравила для заданийсложны, но сейчас достаточно хорошо, чтобы предположить, что типы должны быть идентичными.

Как только мы вышли из типа дляS, мы можем посмотреть натип ограничениядляSПолем В нем говорится:~Символ - чтоОсновной типизSДолжен быть ломтик[]EПолем Основной типSявляется[]int, поэтому[]intдолжен соответствовать[]E, и с этим мы можем сделать вывод, чтоEдолжно бытьintПолем Мы смогли найти типы дляSиEТакие, что соответствующие типы совпадают. Вывод добился успеха!

Вот более сложный сценарий, в котором у нас есть много параметров типа:S1ВS2ВE1, иE2отslices.EqualFunc, иE1иE2от общей функцииequalПолем Локальная функцияfooвызовыslices.EqualFuncсequalфункционируйте как аргумент:

// From the slices package
// func EqualFunc[S1 ~[]E1, S2 ~[]E2, E1, E2 any](s1 S1, s2 S2, eq func(E1, E2) bool) bool

// Local code
func equal[E1, E2 comparable](E1, E2) bool { … }

func foo(list1 []int, list2 []float64) {
    …
    if slices.EqualFunc(list1, list2, equal) {
        …
    }
    …
}

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

Это помогает взглянуть на тип вывода немного по -другому.

Тип уравнений

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

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

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

type List []int
var list List
slices.Sort(list)

Вывод достигает успеха, если приведенные ниже уравнения могут быть решены. Здесьозначаетидентичен, иunder(S)представляетОсновной типизS:

S ≡ List        // find S such that S ≡ List is true
under(S) ≡ []E  // find E such that under(S) ≡ []E is true

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

Точное с типовыми отношениями

До сих пор мы просто говорили о типах, которые должны бытьидентичныйПолем Но для фактического кода GO, это слишком сильное требование. В предыдущем примере,Sне нужно быть идентичнымList, скорееListдолжно бытьназначенкSПолем Сходным образом,Sдолженудовлетворятьего соответствующий тип ограничения. Мы можем сформулировать наши уравнения типа более точно, используя конкретных операторов, которые мы пишем как:≡и:

S :≡ List         // List is assignable to S
S ∈ ~[]E          // S satisfies constraint ~[]E
E ∈ cmp.Ordered   // E satisfies constraint cmp.Ordered

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

X ≡ Y             // X and Y must be identical
X :≡ Y            // Y is assignable to X
X ∈ Y             // X satisfies constraint Y

(Примечание: в разговоре Gophercon мы использовали символыА для:≡иC дляПолем Мы верим:≡более четко вызывает отношение присвоения; инепосредственно выражает, что тип, представленный параметром типа, должен быть элементом его ограниченияТип набор.)

Источники типовых уравнений

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

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

map[K]V ≡ map[int]string  // K ➞ int, V ➞ string (n = 2, m = 1)

Давайте рассмотрим каждый из этих источников типовых уравнений в свою очередь:

1. Тип уравнений из аргументов типа

Для каждого объявления параметров типа

func f[…, P constraint, …]…

и явно предоставил аргумент типа

f[…, A, …]…

Мы получаем уравнение типа

P ≡ A

Мы можем тривиально решить это дляP: Pдолжно бытьAИ мы пишемP ➞ AПолем Другими словами, здесь нечего делать. Мы все еще могли бы записать соответствующее уравнение типа для полноты, но в этом случае компилятор GO просто заменяет аргументы типа для их параметров типа, а затем исчезают эти параметры, и мы можем забыть о них.

2. Тип уравнений из назначений

Для каждого аргумента функцииxПередается в параметр функцииp

f(…, x, …)

гдеpилиxсодержать параметры типа, типxДолжен быть назначен типом параметраpПолем Мы можем выразить это с уравнением

𝑻(p) :≡ 𝑻(x)

где𝑻(x)означает «типx”. Если ни одинpниxСодержит параметры типа, не существует переменной типа для решения для решения для: Уравнение либо true, потому что присвоение действительным код GO, либо FALSE, если код недействителен. По этой причине вывод типа рассматривает только типы, которые содержат типовые параметры вовлеченной функции (или функций).

Начиная с GO 1.21, удаленная или частично созданная функция (но не функциональная вызов) также может быть назначена переменной типа функции, как в:

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

var intSort func([]int) = slices.Sort

Аналогично прохождению параметров, такие назначения приводят к соответствующему уравнению типа. Для этого примера это было бы

𝑻(intSort) :≡ 𝑻(slices.Sort)

или упрощен

func([]int) :≡ func(S)

вместе с уравнениями для ограничений дляSиEотslices.Sort(см. ниже).

3. Тип уравнений из ограничений

Наконец, для каждого параметра типаPДля которого мы хотим вывести аргумент типа, мы можем извлечь уравнение типа из его ограничения, потому что параметр типа должен удовлетворить ограничение. Учитывая декларацию

func f[…, P constraint, …]…

Мы можем записать уравнение

P ∈ constraint

Здесьозначает «должен удовлетворить ограничение», которое (почти) то же самое, что является элементом типа типа ограничения. Позже увидим, что некоторые ограничения (такие какany) не полезны или в настоящее время не могут использоваться из -за ограничений реализации. Вывод просто игнорирует соответствующие уравнения в этих случаях.

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

В GO 1.18 параметры предполагаемого типа должны были быть из той же функции. В частности, было невозможно пройти общую, удаленную или частично создательную функцию в качестве аргумента функции или назначить ее (функциональной) переменной.

Как упоминалось ранее, в этих случаях также работает вывод типа 1.21. Например, общая функция

func myEq[P comparable](x, y P) bool { return x == y }

может быть назначен переменной типа функции

var strEq func(x, y string) bool = myEq  // same as using myEq[string]

безmyEqполностью экземпляры, а вывод типа сделает вывод, что аргумент типа дляPдолжно бытьstringПолем

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

// From the slices package
// func CompactFunc[S ~[]E, E any](s S, eq func(E, E) bool) S

type List []int
var list List
result := slices.CompactFunc(list, myEq)  // same as using slices.CompactFunc[List, int](list, myEq[int])

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

Type parameters and constraints:
    S ~[]E
    E any
    P comparable

Explicit type arguments:
    none

Type equations:
    S :≡ List
    func(E, E) bool :≡ func(P, P) bool
    S ∈ ~[]E
    E ∈ any
    P ∈ comparable

Solution:
    S ➞ List
    E ➞ int
    P ➞ int

Связанные с параметрами свободного типа

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

// From the slices package
// func Sort[S ~[]E, E cmp.Ordered](x S)

// sortedPrint prints the elements of the provided list in sorted order.
func sortedPrint[F any](list []F) {
    slices.Sort(list)  // 𝑻(list) is []F
    …                  // print list
}

Мы хотим сделать вывод аргумента дляslices.Sortвызов. Прохождениеlistк параметруxизslices.Sortпорождает уравнение

𝑻(x) :≡ 𝑻(list)

что такое же, как

S :≡ []F

В этом уравнении у нас есть два параметра типа,SиFПолем Для какого из них нам нужно решить уравнение типа? Потому что вызываемая функцияSort, мы заботимся о его параметре типаS, не параметр типаFПолем Мы говорим этоSявляетсяграницакSortПотому что это объявленоSortПолемSэто соответствующая переменная типа в этом уравнении. В отличие от этого,Fсвязан (объявлено)sortedPrintПолем Мы говорим этоFявляетсябесплатноЧто касаетсяSortПолем У него есть свой, уже данный тип. Этот тип естьF, что бы это ни было (определено во время экземпляра). В этом уравнении,Fуже дается, этотип постояннойПолем

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

Решение уравнений типа

Отсутствующая часть, теперь, когда мы установили, как собирать соответствующие параметры типа и уравнения типа, это, конечно, алгоритм, который позволяет нам решать уравнения. После различных примеров, вероятно, стало очевидно, что решениеX ≡ YПросто означает сравнение типовXиYрекурсивно друг против друга и в процессе, определяющий подходящие аргументы типа для параметров типа, которые могут возникнуть вXиYПолем Цель состоит в том, чтобы сделать типыXиYидентичныйПолем Этот процесс сопоставления называетсяобъединениеПолем

Правила дляТип идентификацияРасскажите нам, как сравнить типы. СграницаПараметры типа играют роль переменных типа, нам нужно указать, как они сопоставлены с другими типами. Правила следующие:

  • Если тип параметраPимеет предполагаемый тип,Pозначает этот тип.
  • Если тип параметраPнет предполагаемого типа и соответствует другому типуTВPустанавливается на этот тип:P ➞ TПолем Мы говорим, что типTбыл выведен дляPПолем
  • ЕслиPсовпадает с другим параметром типаQи ни одинPниQесть предполагаемый тип еще,PиQявляютсяобъединенныйПолем

Объединение двух параметров типа означает, что они соединяются вместе так, чтобы в будущем они обозначали одно и то же значение параметра типа: если один изPилиQсоответствует типуT, обаPиQустановлены наTодновременно (в целом любое количество параметров типа может быть объединенным таким образом).

Наконец, если два типаXиYотличаются, уравнение не может быть сделано правдой и решением оно не удается.

Объединение типов для идентичности типа

Несколько конкретных примеров должны прояснить этот алгоритм. Рассмотрим два типаXиYСодержит три параметра связанных типовAВB, иC, все появляются в типовом уравненииX ≡ YПолем Цель состоит в том, чтобы решить это уравнение для параметров типа; то есть найти подходящие аргументы для них такими, чтоXиYСтаньте идентичным, и, таким образом, уравнение становится истинным.

X: map[A]struct{i int; s []B}
Y: map[string]struct{i C; s []byte}

Объединение продолжается, сравнивая структуруXиYрекурсивно, начиная с вершины. Просто просмотр структуры двух типов, которые у нас есть

map[…]… ≡ map[…]…

спредставляя соответствующие ключа карты и типы значений, которые мы игнорируем на этом шаге. Поскольку у нас есть карта с обеих сторон, типы до сих пор идентичны. Объединение продолжается рекурсивно, сначала с ключевыми типами, которыеAдляXкарта иstringдляYкарта Соответствующие типы ключеAдолжно бытьstring:

A ≡ string => A ➞ string

Продолжая с типами элементов карты, мы прибываем на

struct{i int; s []B} ≡ struct{i C; s []byte}

Обе стороны являются структурой, поэтому объединение продолжается с полями структуры. Они идентичны, если они в том же порядке, с одинаковыми именами и идентичными типами. Первая пара поляi intиi CПолем Имена совпадают и потому чтоintдолжен объединиться сC, таким образом

int ≡ C => C ➞ int

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

[]B ≡ []byte => B ≡ byte => B ➞ byte

Все работает нормально, и объединение позволяет аргументам типа

A ➞ string
B ➞ byte
C ➞ int

Объединение типов с разными структурами

Теперь давайте рассмотрим небольшой вариант предыдущего примера: здесьXиYне имеют такой же структуры типа. Когда типовые деревья сравниваются рекурсивно, объединение все еще успешно делает аргумент типа дляAПолем Но типы значений карт различны, а объединение терпит неудачу.

X: map[A]struct{i int; s []B}
Y: map[string]bool

ОбаXиYявляются типами карт, поэтому объединение продолжается рекурсивно, как и раньше, начиная с типов ключей. Мы прибываем

A ≡ string => A ➞ string

Также, как и прежде. Но когда мы переходим к типам значений карты

struct{…} ≡ bool

АstructТип не совпадаетbool; У нас есть разные типы, а объединение (и, следовательно, вывод типа).

Объединение типов с противоречивыми аргументами типа

Другой вид конфликта появляется, когда разные типы совпадают с одним и тем же параметром типа. Здесь у нас снова есть версия нашего первоначального примера, но теперь параметр типаAпоявляется дваждыX, иCпоявляется дваждыYПолем

X: map[A]struct{i int; s []A}
Y: map[string]struct{i C; s []C}

Объединение рекурсивного типа сначала работает нормально, и у нас есть следующие пары параметров и типов типа:

A   ≡ string => A ➞ string  // map key type
int ≡ C      => C ➞ int     // first struct field type

Когда мы добираемся до второго типа поля структуры, у нас

[]A ≡ []C => A ≡ C

С тех пор обаAиCесть тип аргумента, выведенный для них, они обозначают эти аргументы, которыеstringиintсоответственно. Это разные типы, поэтомуAиCНе могу соответствовать. Объединение и, следовательно, вывод типа не удается.

Другие типовые отношения

Объединение решает типовые уравнения формыX ≡ Yгде цельТип идентификацияПолем Но как насчетX :≡ YилиX ∈ Y?

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

Если вывод типа недостаточно точен, он может сделать вывод (неправильный) аргумент типа, где не может быть типа. Если это так, либо экземпляр, либо прохождение аргументов потерпит неудачу. В любом случае, компилятор будет создавать сообщение об ошибке. Просто сообщение об ошибке может быть немного другим.

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

Упрощение x: ≡ y

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

  • Когда названный (определенный) тип сопоставлен с литералом типа, вместо этого сравниваются их базовые типы.
  • При сравнении типов каналов направления канала игнорируются.

Кроме того, направление назначения игнорируется:X :≡ Yотносится как кY :≡ XПолем

Эти корректировки применяются только на верхнем уровне структуры типа: например, за GOПравила назначения, названный тип карты может быть назначен неназванным типам карты, но типы ключей и элементов все еще должны быть идентичными. С этими изменениями объединение для назначения становится (незначительным) изменением объединения для идентификации типа. Следующий пример иллюстрирует это.

Давайте предположим, что мы передаем ценность нашего более раннегоListТип (определяется какtype List []int) к параметру функции типа[]EгдеEпараметр связанного типа (т.е.Eобъявляется общей функцией, которая называется). Это приводит к уравнению типа[]E :≡ ListПолем

Попытка объединить эти два типа требует сравнения[]EсListЭти два типа не идентичны, и без каких -либо изменений в том, как работает объединение, оно не удастся. Но поскольку мы объединяемся для назначения, этот первоначальный матч не должен быть точным.

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

Упрощение x ∈ Y

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

Опять же, удовлетворение ограничений проверяется во время экземпляра, поэтому цель здесь состоит в том, чтобы помочь типу вывода, где мы можем. Обычно это ситуации, когда мы знаем структуру параметра типа; Например, мы знаем, что это должен быть тип среза, и мы заботимся о типе элемента среза. Например, список параметров типа формы[P ~[]E]говорит нам, что угодноPэто, его базовый тип должен иметь форму[]EПолем Это именно те ситуации, когда ограничение имеетОсновной типПолем

Поэтому, если у нас есть уравнение формы

P ∈ constraint               // or
P ∈ ~constraint

и еслиcore(constraint)(илиcore(~constraint)соответственно) существует, уравнение может быть упрощено до

P        ≡ core(constraint)
under(P) ≡ core(~constraint)  // respectively

Во всех других случаях уравнения типа, включающие ограничения, игнорируются.

Расширение предполагаемых типов

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

func g[A any, B []C, C *A](x A) { … }

var x int
g(x)

Ограничение типа дляAявляетсяanyкоторый не имеет основного типа, поэтому мы игнорируем его. Оставшиеся ограничения типа имеют основные типы, и они[]Cи*Aсоответственно. Вместе с аргументом, переданнымg, после незначительных упрощений, типовые уравнения:

    A :≡ int
    B ≡ []C
    C ≡ *A

Поскольку каждое уравнение связывает параметр типа против типа параметра, не являющегося типом, Unitific

    A ➞ int
    B ➞ []C
    C ➞ *A

Но это оставляет параметры типаAиCВ предполагаемых типах, что не полезно. Как и в алгебре средней школы, после того, как уравнение решается для переменнойx, нам нужно заменитьxс его значением на протяжении оставшихся уравнений. В нашем примере на первом шагеCв[]Cзаменяется предполагаемым типом («значение») дляC, что есть*A, и мы прибываем в

    A ➞ int
    B ➞ []*A    // substituted *A for C
    C ➞ *A

Еще на два шага мы заменимAв предполагаемых типах[]*Aи*Aс предполагаемым типом дляA, что естьint:

    A ➞ int
    B ➞ []*int  // substituted int for A
    C ➞ *int    // substituted int for A

Только сейчас вывод сделан. И, как в средней школе алгебры, иногда это не работает. Можно прийти к такой ситуации, как

    X ➞ Y
    Y ➞ *X

После одного раунда замены у нас

    X ➞ *X

Если мы продолжим, предполагаемый тип дляXпродолжает расти:

    X ➞ **X     // substituted *X for X
    X ➞ ***X    // substituted *X for X
    etc.

Тип вывода обнаруживает такие циклы во время расширения и сообщает об ошибке (и, следовательно, сбой).

Нетистные постоянные

К настоящему времени мы увидели, как работает тип вывода, решая уравнения типа с объединением, за которым следует расширение результата. Но что, если нет типов? Что если аргументы функции являются нетипированными константами?

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

func foo[P any](...P) {}

var x int
foo(x)         // P ➞ int, same as foo[int](x)
foo(x, 2.0)    // P ➞ int, 2.0 converts to int without loss of precision
foo(x, 2.1)    // P ➞ int, but parameter passing fails: 2.1 is not assignable to int

Для вывода типа напечатанные аргументы имеют приоритет по сравнению с нетипированными аргументами. Неугоминная константа рассматривается только для вывода только в том случае, если параметр типа, которому он назначен, пока не имеет предполагаемого типа. В этих первых трех призывах кfoo, переменнаяxопределяет предполагаемый тип дляP: Это типxкоторый естьintПолем Нетипедные константы игнорируются для вывода типа в этом случае, и звонки ведут себя точно так же, как если быfooбыл явно создан сintПолем

Становится все интереснее, еслиfooвызывается только с нетипированными постоянными аргументами. В этом случае вывод типа учитываетТипы по умолчаниюнетипированных констант. В качестве быстрого напоминания, вот возможные типы по умолчанию в Go:

Example     Constant kind              Default type    Order

true        boolean constant           bool
42          integer constant           int             earlier in list
'x'         rune constant              rune               |
3.1416      floating-point constant    float64            v
-1i         complex constant           complex128      later in list
"gopher"    string constant            string

С помощью этой информации давайте рассмотрим вызов функции

foo(1, 2)    // P ➞ int (default type for 1 and 2)

Нетипные постоянные аргументы1и2оба целочисленные постоянные, их тип по умолчаниюintи таким образом этоintэто выводится для параметра типаPизfooПолем

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

foo(1, 2.0)    // Go 1.20: inference error: default types int, float64 don't match

Такое поведение было не очень эргономичным в использовании, а также отличалось от поведения нетипированных констант в выражениях. Например, GO разрешает постоянное выражение1 + 2.0; Результатом является постоянная с плавающей точкой3.0с типом по умолчаниюfloat64Полем

В Go 1.21 поведение было изменено соответственно. Теперь, если несколько нетипированных численных константов сопоставлены с одним и тем же параметром, тип по умолчанию, который появляется позже в спискеintВruneВfloat64Вcomplexвыбрано, соответствующее правилам дляпостоянные выражения:

foo(1, 2.0)    // Go 1.21: P ➞ float64 (larger default type of 1 and 2.0; behavior like in 1 + 2.0)

Специальные ситуации

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

Зависимости заказа параметров

Первый связан с зависимостями заказа параметров. Важным свойством, которое мы хотим от вывода типа, является то, что те же типы выводятся независимо от порядка параметров функции (и соответствующий порядок аргумента в каждом вызове этой функции).

Давайте пересмотрим нашу варидическуюfooФункция: тип, предполагаемый дляPдолжен быть таким же независимо от порядка, в котором мы передаем аргументыsиt (детская площадка)

func foo[P any](...P) (x P) {}

type T struct{}

func main() {
    var s struct{}
    var t T
    fmt.Printf("%T\n", foo(s, t))
    fmt.Printf("%T\n", foo(t, s)) // expect same result independent of parameter order
}

От звонков кfooМы можем извлечь соответствующие уравнения типа:

𝑻(x) :≡ 𝑻(s) => P :≡ struct{}    // equation 1
𝑻(x) :≡ 𝑻(t) => P :≡ T           // equation 2

К сожалению, упрощенная реализация для:≡создает зависимость заказа:

Если объединение начинается с уравнения 1, оно соответствуетPпротивstruct; Pпока не имеет этого типа, и, следовательно, объединениеP ➞ struct{}Полем Когда объединение видит типTПозднее в уравнении 2 он продолжается с базовым типомTкоторый естьstruct{}ВPиunder(T)Объединение и объединение и, следовательно, вывод достигает успеха.

Наоборот, если объединение начинается с уравнения 2, оно соответствуетPпротивT; Pпока не имеет этого типа, и, следовательно, объединениеP ➞ TПолем Когда объединение видитstruct{}Позже в уравнении 1 он продолжается с базовым типом типаTвыводится дляPПолем Этот основной типstruct{}, что соответствуетstructв уравнении 1, а также объединение и, следовательно, вывод достигает успеха.

Как следствие, в зависимости от порядка, в котором объединение решает два уравнения типа, предполагаемый тип либо илиstruct{}илиTПолем Это, конечно, неудовлетворительно: программа может внезапно перестать собирать просто потому, что аргументы могли быть перетасованы во время рефакторинга кода или очистки.

Восстановление независимости порядка

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

В частности, если объединение решаетP :≡ Tи

  • Pпараметр типа, который уже вышелA: P ➞ A
  • A :≡ Tэто правда
  • Tэто названный тип

затем установите предполагаемый тип дляPкT: P ➞ T

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

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

Возвращаясь к нашему примеру, если объединение начинается с уравнения 1, оно проживаетP ➞ struct{}как раньше. Когда он продолжается с уравнением 2, как и прежде, объединение преуспевает, но теперь у нас есть именно то условие, которое требует коррекции:Pэто параметр типа, который уже имеет тип (struct{}), struct{}Вstruct{} :≡ Tэто правда (потому чтоstruct{} ≡ under(T)это правда), иTэто названный тип. Таким образом, объединение создает коррекцию и наборыP ➞ TПолем В результате, независимо от порядка объединения, результат такой же (T) в обоих случаях.

Саморекурсирующие функции

Другим сценарием, который вызывает проблемы в наивной реализации вывода, является саморекурсирующие функции. Давайте рассмотрим общую факторную функциюfact, определено таким образом, что он также работает для аргументов с плавающей точкой (детская площадка) Обратите внимание, что это не математически правильная реализацияГамма -функция, это просто удобный пример.

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return fact(n-1) * n
}

Смысл здесь не факториальная функция, а скорееfactвызывает себя аргументомn-1что такого же типаPв качестве входящего параметраnПолем В этом вызове параметр типаPодновременно связан и параметр свободного типа: он связан, потому что он объявленfact, функция, которую мы называем рекурсивно. Но это также бесплатно, потому что она объявлена ​​функцией, заключающей вызов, который также оказываетсяfactПолем

Уравнение в результате передачи аргументаn-1к параметруnямыPпротив себя:

𝑻(n) :≡ 𝑻(n-1) => P :≡ P

Объединение видит то же самоеPпо обе стороны уравнения. Объединение добивается успеха, поскольку оба типа идентичны, но информация не получена и не полученаPостается без предполагаемого типа. Как следствие, вывод типа не удается.

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

Для целей этого примера давайте предположимPв подписиfactбыл переименован вQПолем Эффект, как будто рекурсивный вызов был сделан косвенно черезhelperфункция (детская площадка):

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return helper(n-1) * n
}

func helper[Q ~int | ~float64](n Q) Q {
    return fact(n)
}

С переименованием или сhelperфункция, уравнение, возникающее в результате прохожденияn-1к рекурсивному вызовуfact(илиhelperфункционировать, соответственно) изменения в

𝑻(n) :≡ 𝑻(n-1) => Q :≡ P

Это уравнение имеет два параметра типа: параметр граничного типаQ, объявлена ​​функцией, которая вызывается, и параметром свободного типаP, объявлено функцией окружения. Это уравнение типа тривиально решено дляQи приводит к выводуQ ➞ PЧто, конечно, то, чего мы ожидаем, и что мы можем проверить, явно создав рекурсивный вызов (детская площадка):

func fact[P ~int | ~float64](n P) P {
    if n <= 1 {
        return 1
    }
    return fact[P](n-1) * n
}

Чего не хватает?

Явно отсутствует в нашем описании, тип вывод для общих типов: в настоящее время общие типы всегда должны быть явно созданы.

Есть несколько причин для этого. Прежде всего, для создания типов, вывод типа имеет только типовые аргументы для работы; Нет других аргументов, как в случае вызовов функций. Как следствие, по крайней мере один аргумент типа всегда должен быть предоставлен (за исключением патологических случаев, когда ограничения типа предписывают именно один возможный аргумент типа для всех параметров типа). Таким образом, тип вывод для типов полезен только для завершения частично создательного типа, где все аргументы пропущенных типов могут быть выведены из уравнений, возникающих в результате ограничений типа; то есть, где есть как минимум два параметра типа. Мы считаем, что это не очень распространенный сценарий.

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

type T[P T[P]] interface{ … }

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

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

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

Тем не менее, если вы столкнетесь с ситуацией, когда вы верите, что вывод типа должен работать или сбиться с пути, пожалуйстаподать проблему! Как всегда, команда GO любит слышать от вас, особенно когда это помогает нам сделать еще лучше.


Роберт Грисмер

ФотоJr KorpaнаНеспособный

Эта статья доступна наБлог GOПод CC по лицензии 4,0.


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