Дополнительные сведения о типах

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

Типы и наборы (и Any и Union{}/Bottom)

Возможно, проще всего представить себе систему типов Julia с точки зрения наборов. В то время как программы работают с отдельными значениями, тип относится к набору значений. Это не то же самое, что коллекция. Например, набор (Set) значений сам по себе является одним значением Set. Скорее, тип описывает набор возможных значений, выражая неопределенность относительно того, какое значение есть у нас.

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

Any описывает всю совокупность возможных значений. Integer — это подмножество Any, которое содержит Int, Int8 и другие конкретные типы. Внутри Julia также активно использует другой тип, известный как Bottom, который также может быть записан как Union{}. Он соответствует пустому набору.

Типы Julia поддерживают стандартные операции теории множеств: вы можете спросить, является ли T1 «подмножеством» (подтипом) T2 с T1 <: T2. Точно так же вы пересекаете два типа с помощью функции typeintersect, берете их объединение с помощью типа Union и вычисляете тип, который содержит их объединение, с помощью функции typejoin:

julia> typeintersect(Int, Float64)
Union{}

julia> Union{Int, Float64}
Union{Float64, Int64}

julia> typejoin(Int, Float64)
Real

julia> typeintersect(Signed, Union{UInt8, Int8})
Int8

julia> Union{Signed, Union{UInt8, Int8}}
Union{UInt8, Signed}

julia> typejoin(Signed, Union{UInt8, Int8})
Integer

julia> typeintersect(Tuple{Integer, Float64}, Tuple{Int, Real})
Tuple{Int64, Float64}

julia> Union{Tuple{Integer, Float64}, Tuple{Int, Real}}
Union{Tuple{Int64, Real}, Tuple{Integer, Float64}}

julia> typejoin(Tuple{Integer, Float64}, Tuple{Int, Real})
Tuple{Integer, Real}

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

Типы UnionAll

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

Например, тип Array имеет два параметра, как показано в Array{Int,2}. Если бы мы не знали тип элемента, мы могли бы написать Array{T,2} where T, который является объединением Array{T,2} для всех значений T: Union{Array{Int8,2}, Array{Int16,2}, ...}.

Такой тип представлен объектом UnionAll, который содержит переменную (в данном примере — T, имеющую тип TypeVar) и заключенный в оболочку тип (в данном примере — Array{T,2}).

Рассмотрим следующие методы.

f1(A::Array) = 1
f2(A::Array{Int}) = 2
f3(A::Array{T}) where {T<:Any} = 3
f4(A::Array{Any}) = 4

Сигнатура (как описано в разделе Вызовы функций) f3 является типом UnionAll, инкапсулирующим тип кортежа: Tuple{typeof(f3), Array{T}} where T. Все, кроме f4, можно вызвать с помощью a = [1,2]. Все, кроме f2, можно вызвать с помощью b = Any[1,2].

Рассмотрим эти типы подробнее.

julia> dump(Array)
UnionAll
  var: TypeVar
    name: Symbol T
    lb: Union{}
    ub: Any
  body: UnionAll
    var: TypeVar
      name: Symbol N
      lb: Union{}
      ub: Any
    body: Array{T, N} <: DenseArray{T, N}

Это указывает на то, что Array на самом деле именует тип UnionAll. Для каждого параметра существует один вложенный тип UnionAll. Синтаксис Array{Int,2} эквивалентен Array{Int}{2}. Внутренне экземпляр каждого типа UnionAll создается с определенным значением переменной, по одному за раз, начиная с самого внешнего. В этом случае опускание конечных параметров типа приобретает естественное значение. Array{Int} выдает тип, эквивалентный Array{Int,N} where N.

TypeVar сам по себе не является типом, а скорее должен рассматриваться как часть структуры типа UnionAll. Переменные типа имеют нижнюю и верхнюю границы для своих значений (в полях lb и ub). Символьное имя name является чисто декоративным. Внутренне типы TypeVar сравниваются по адресу, поэтому они определены как изменяемые, чтобы можно было различать переменные «разных» типов. Однако по соглашению они не должны быть изменяемыми.

Типы TypeVar можно построить вручную:

julia> TypeVar(:V, Signed, Real)
Signed<:V<:Real

Существуют удобные версии, которые позволяют опускать любой из этих аргументов, кроме символа name.

Синтаксис Array{T} where T<:Integer понижается до следующего:

let T = TypeVar(:T,Integer)
    UnionAll(T, Array{T})
end

поэтому TypeVar редко требуется создавать вручную (более того, этого следует избегать).

Свободные переменные

Понятие переменной свободного типа является чрезвычайно важным в системе типов. Мы говорим, что переменная V имеет свободный тип T, если T не содержит тип UnionAll, который представляет переменную V. Например, тип Array{Array{V} where V<:Integer} не имеет свободных переменных, но часть Array{V} внутри него имеет свободную переменную V.

Тип со свободными переменными в некотором смысле вообще не является типом. Рассмотрим тип Array{Array{T}} where T, который относится ко всем однородным массивам массивов. Может показаться, что внутренний тип Array{T}, рассматриваемый сам по себе, относится к любому виду массива. Однако каждый элемент внешнего массива должен иметь тот же тип массива, поэтому Array{T} не может относиться к любому старому массиву. Можно сказать, что Array{T} фактически «возникает» несколько раз, и T должен иметь одно и то же значение каждый «раз».

По этой причине функция jl_has_free_typevars в API для C очень важна. Типы, для которых она возвращает значение true, не дадут вразумительных ответов в подтипизации и других функциях типа.

TypeNames

Следующие два типа Array функционально эквивалентны, но выводятся по-разному:

julia> TV, NV = TypeVar(:T), TypeVar(:N)
(T, N)

julia> Array
Array

julia> Array{TV, NV}
Array{T, N}

Их можно отличить, изучив поле name типа, которое является объектом типа TypeName:

julia> dump(Array{Int,1}.name)
TypeName
  name: Symbol Array
  module: Module Core
  names: empty SimpleVector
  wrapper: UnionAll
    var: TypeVar
      name: Symbol T
      lb: Union{}
      ub: Any
    body: UnionAll
      var: TypeVar
        name: Symbol N
        lb: Union{}
        ub: Any
      body: Array{T, N} <: DenseArray{T, N}
  cache: SimpleVector
    ...

  linearcache: SimpleVector
    ...

  hash: Int64 -7900426068641098781
  mt: MethodTable
    name: Symbol Array
    defs: Nothing nothing
    cache: Nothing nothing
    max_args: Int64 0
    module: Module Core
    : Int64 0
    : Int64 0

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

julia> pointer_from_objref(Array)
Ptr{Cvoid} @0x00007fcc7de64850

julia> pointer_from_objref(Array.body.body.name.wrapper)
Ptr{Cvoid} @0x00007fcc7de64850

julia> pointer_from_objref(Array{TV,NV})
Ptr{Cvoid} @0x00007fcc80c4d930

julia> pointer_from_objref(Array{TV,NV}.name.wrapper)
Ptr{Cvoid} @0x00007fcc7de64850

Поле wrapper в Array указывает на себя, но для Array{TV,NV} оно указывает на исходное определение типа.

А как насчет других полей? hash присваивает целочисленное значение каждому типу. Для изучения поля cache рекомендуется выбрать тип, который используется менее активно, чем Array. Сначала создадим собственный тип:

julia> struct MyType{T,N} end

julia> MyType{Int,2}
MyType{Int64, 2}

julia> MyType{Float32, 5}
MyType{Float32, 5}

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

Типы кортежей

Типы кортежей представляют собой интересный частный случай. Чтобы диспетчеризация работала с объявлениями, подобными x::Tuple, тип должен быть способен вмещать любой кортеж. Проверим параметры:

julia> Tuple
Tuple

julia> Tuple.parameters
svec(Vararg{Any})

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

julia> typeintersect(Tuple, Tuple{Int,Float64})
Tuple{Int64, Float64}

julia> typeintersect(Tuple{Vararg{Any}}, Tuple{Int,Float64})
Tuple{Int64, Float64}

Однако, если тип кортежа с переменным количеством аргументов (Vararg) имеет свободные переменные, он может описывать различные типы кортежей:

julia> typeintersect(Tuple{Vararg{T} where T}, Tuple{Int,Float64})
Tuple{Int64, Float64}

julia> typeintersect(Tuple{Vararg{T}} where T, Tuple{Int,Float64})
Union{}

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

Наконец, стоит заметить, что Tuple{} является другим:

julia> Tuple{}
Tuple{}

julia> Tuple{}.parameters
svec()

julia> typeintersect(Tuple{}, Tuple{Int})
Union{}

Что такое «основной» тип кортежа?

julia> pointer_from_objref(Tuple)
Ptr{Cvoid} @0x00007f5998a04370

julia> pointer_from_objref(Tuple{})
Ptr{Cvoid} @0x00007f5998a570d0

julia> pointer_from_objref(Tuple.name.wrapper)
Ptr{Cvoid} @0x00007f5998a04370

julia> pointer_from_objref(Tuple{}.name.wrapper)
Ptr{Cvoid} @0x00007f5998a04370

поэтому Tuple == Tuple{Vararg{Any}} действительно является основным типом.

Диагональные типы

Рассмотрим тип Tuple{T,T} where T. Метод с такой сигнатурой будет выглядеть следующим образом:

f(x::T, y::T) where {T} = ...

Согласно обычной интерпретации типа UnionAll, этот тип T распространяется на все типы, включая Any, поэтому он должен быть эквивалентен Tuple{Any,Any}. Однако такая интерпретация приводит к возникновению ряда практических проблем.

Во-первых, значение T должно быть доступно внутри определения метода. Для такого вызова, как f(1, 1.0), неясно, каким должен быть тип T. Это может быть Union{Int,Float64}, или, возможно, Real. Интуитивно мы ожидаем, что объявление x::T будет означать T === typeof(x). Чтобы убедиться в том, что инвариант сохраняется, нам требуется typeof(x) === typeof(y) === T в этом методе. Это означает, что метод должен вызываться только для аргументов точно такого же типа.

Оказывается, возможность отправлять данные о том, имеют ли два значения одинаковый тип, очень полезна (она используется, например, в системе продвижения), поэтому у нас есть несколько причин, чтобы получить другую интерпретацию Tuple{T,T} where T. Для этого мы добавляем следующее правило к подтипизации: если переменная встречается более одного раза в ковариантной позиции, она ограничивается диапазоном только конкретных типов. («Ковариантная позиция» означает, что между вхождением переменной и вводимым ею типом UnionAll встречаются только типы Tuple и Union.) Такие переменные называются «диагональными переменными» или «конкретными переменными».

Так, например, Tuple{T,T} where T можно рассматривать как Union{Tuple{Int8,Int8}, Tuple{Int16,Int16}, ...}, где тип T охватывает все конкретные типы. Это приводит к некоторым интересным результатам подтипизации. Например, Tuple{Real,Real} не является подтипом типа Tuple{T,T} where T, поскольку он включает некоторые типы, такие как Tuple{Int8,Int16}, где два этих элемента имеют разные типы. Tuple{Real,Real} и Tuple{T,T} where T имеют нетривиальное пересечение Tuple{T,T} where T<:Real. Однако Tuple{Real} является подтипом типа Tuple{T} where T, потому что в этом случае тип T встречается только один раз и поэтому не является диагональным.

Далее рассмотрим сигнатуру следующего вида:

f(a::Array{T}, x::T, y::T) where {T} = ...

В этом случае тип T появляется в инвариантной позиции внутри Array{T}. Это означает, что какой бы тип массива ни был передан, он однозначно определяет значение типа T — мы говорим, что тип T имеет ограничение равенства. Поэтому в данном случае правило диагонали не является необходимым, так как массив определяет тип T, и можно разрешить x и y быть любыми подтипами типа T. Поэтому переменные, которые встречаются в инвариантной позиции, никогда не считаются диагональными. Этот выбор поведения является несколько спорным — некоторые считают, что это определение должно быть записано как

f(a::Array{T}, x::S, y::S) where {T, S<:T} = ...

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

Следующей сложностью является взаимодействие объединений и диагональных переменных, например:

f(x::Union{Nothing,T}, y::T) where {T} = ...

Рассмотрим, что означает это объявление. y имеет тип T. Тогда x может иметь либо тот же тип T, либо тип Nothing. Таким образом, все следующие вызовы должны совпадать:

f(1, 1)
f("", "")
f(2.0, 2.0)
f(nothing, 1)
f(nothing, "")
f(nothing, 2.0)

Эти примеры говорят о следующем: когда x имеет значение nothing::Nothing, для y нет никаких дополнительных ограничений. Это как если бы в сигнатуре метода было y::Any. На самом деле мы имеем следующую эквивалентность типов:

(Tuple{Union{Nothing,T},T} where T) == Union{Tuple{Nothing,Any}, Tuple{T,T} where T}

Общее правило таково: конкретная переменная в ковариантной позиции ведет себя как неконкретная, если алгоритм подтипизации использует ее только один раз. Когда x имеет тип Nothing, не нужно использовать тип T в Union{Nothing,T}, он используется только во втором слоте. Это обусловлено тем, что в Tuple{T} where T ограничение типа T конкретными типами не имеет никакого значения. Тип равен Tuple{Any} в любом случае.

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

Tuple{Int,Int8,Vector{Integer}} <: Tuple{T,T,Vector{Union{Integer,T}}} where T

Если тип T внутри Union игнорируется, то тип T будет конкретным, и ответ будет false, так как первые два типа не одинаковы. Теперь рассмотрим следующее.

Tuple{Int,Int8,Vector{Any}} <: Tuple{T,T,Vector{Union{Integer,T}}} where T

Сейчас мы не можем игнорировать тип T в Union (у нас должно быть T == Any), поэтому тип T не является конкретным и ответ — true. В этом случае конкретность T будет находиться в зависимости от другого типа, что недопустимо, поскольку тип должен иметь четкое значение сам по себе. Поэтому появление T внутри Vector рассматривается в обоих случаях.

Подтипизация диагональных переменных

Алгоритм подтипизации для диагональных переменных состоит из двух компонентов: (1) определение вхождений переменных и (2) обеспечение того, чтобы диагональные переменные охватывали только конкретные типы.

Первая задача решается путем ведения счетчиков occurs_inv и occurs_cov (в файле src/subtype.c) для каждой переменной в среде с отслеживанием количества инвариантных и ковариантных вхождений, соответственно. Переменная является диагональной, когда occurs_inv == 0 && occurs_cov > 1.

Вторая задача решается путем наложения условия на нижнюю границу переменной. По мере работы алгоритма подтипизации происходит сужение границ каждой переменной (с повышением нижних и понижением верхних границ) в целях отслеживания диапазона значений переменной, для которых будет выполняться соотношение подтипа. Завершив вычисление тела типа UnionAll, переменная которого является диагональной, посмотрим на конечные значения границ. Поскольку переменная должна быть конкретной, возникает противоречие, если ее нижняя граница не может быть подтипом конкретного типа. Например, абстрактный тип, подобный AbstractArray, не может быть подтипом конкретного типа, но конкретный тип, подобный Int, может, и пустой тип Bottom тоже может. Если нижняя граница не проходит этот тест, алгоритм останавливается с ответом false.

Например, в задаче Tuple{Int,String} <: Tuple{T,T} where T мы получаем, что соотношение было бы верным, если бы T был супертипом Union{Int,String}. Однако Union{Int,String} является абстрактным типом, поэтому соотношение не выполняется.

Этот тест на конкретность проводится с помощью функции is_leaf_bound. Обратите внимание, что этот тест немного отличается от функции jl_is_leaf_type, поскольку он также возвращает true для нижней границы (Bottom). В настоящее время эта функция является эвристической и не перехватывает все возможные конкретные типы. Сложность в том, что конкретность нижней границы может зависеть от значений границ других переменных типа. Например, Vector{T} эквивалентен конкретному типу Vector{Int}, только если верхняя и нижняя границы T равны Int. Мы еще не разработали полный алгоритм действий в этой ситуации.

Знакомство с внутренним механизмом

Большинство операций для работы с типами находятся в файлах jltypes.c и subtype.c. Лучше всего начать с изучения типизации в действии. Выполните сборку Julia с помощью make debug и запустите Julia в отладчике. В главе Советы по отладке gdb вы найдете полезные советы.

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

julia> function mysubtype(a,b)
           ccall(:jl_breakpoint, Cvoid, (Any,), nothing)
           a <: b
       end

и затем установите точку останова в функции jl_breakpoint. После того как эта точка сработает, вы можете установить точки останова в других функциях.

В качестве разминки попробуйте сделать следующее.

mysubtype(Tuple{Int, Float64}, Tuple{Integer, Real})

Повысим уровень и рассмотрим более сложный случай:

mysubtype(Tuple{Array{Int,2}, Int8}, Tuple{Array{T}, T} where T)

Подтипизация и сортировка методов

Функции type_morespecific используются для задания частичного порядка для функций в таблицах методов (от наиболее специфичных до наименее). Специфичность является строгой; если a более специфично, чем b, то a не равно b и b не более специфично, чем a.

Если a является строгим подтипом b, то оно автоматически считается более специфичным. Исходя из этого, type_morespecific использует некоторые менее формальные правила. Например, subtype зависит от количества аргументов, а type_morespecific может не зависеть. В частности, Tuple{Int,AbstractFloat} является более специфичным, чем Tuple{Integer}, даже если он не является подтипом. (Ни Tuple{Int,AbstractFloat}, ни Tuple{Integer,Float64} не является более специфичным, чем другой.) В частности, Tuple{Int,Vararg{Int}} не является подтипом Tuple{Integer}, но считается более специфичным. Однако morespecific получает бонус за длину: в частности, Tuple{Int,Int} более специфичен, чем Tuple{Int,Vararg{Int}}.

Если вы отлаживаете способы сортировки методов, может быть целесообразно определить функцию:

type_morespecific(a, b) = ccall(:jl_type_morespecific, Cint, (Any,Any), a, b)

которая позволяет проверять, является ли тип кортежа a более конкретным, чем тип кортежа b.