Types
Системы типов традиционно делятся на два совершенно разных лагеря: статические системы типов, в которых каждое выражение программы должно иметь тип, вычисляемый до выполнения программы, и динамические системы типов, в которых ничего не известно о типах до времени выполнения, когда доступны фактические значения, обрабатываемые программой. Объектная ориентация позволяет некоторую гибкость в статически типизированных языках, позволяя писать код без точного знания типов значений на этапе компиляции. Способность писать код, который может работать с разными типами, называется полиморфизмом. Весь код в классических динамически типизированных языках является полиморфным: только при явной проверке типов или когда объекты не поддерживают операции во время выполнения, типы любых значений когда-либо ограничиваются.
Типовая система Julia динамическая, но получает некоторые преимущества статических типовых систем, позволяя указывать, что определенные значения имеют конкретные типы. Это может быть очень полезно для генерации эффективного кода, но еще более значительно, что это позволяет глубоко интегрировать диспетчеризацию методов с типами аргументов функции в язык. Диспетчеризация методов подробно рассматривается в Methods, но основывается на типовой системе, представленной здесь.
Поведение по умолчанию в Julia, когда типы опущены, позволяет значениям быть любого типа. Таким образом, можно написать множество полезных функций на Julia, никогда явно не используя типы. Однако, когда требуется дополнительная выразительность, легко постепенно вводить явные аннотации типов в ранее "нетипизированный" код. Добавление аннотаций служит трем основным целям: воспользоваться мощным механизмом множественной диспетчеризации Julia, улучшить читаемость для человека и поймать ошибки программиста.
Описание Julia на языке type systems таково: динамическая, номинативная и параметрическая. Обобщенные типы могут быть параметризованы, а иерархические отношения между типами являются explicitly declared, а не implied by compatible structure. Одной из особенно отличительных черт системы типов Julia является то, что конкретные типы не могут быть подтипами друг друга: все конкретные типы являются финальными и могут иметь только абстрактные типы в качестве своих суперт типов. Хотя это может сначала показаться чрезмерно ограничительным, у этого есть много полезных последствий с удивительно немногими недостатками. Оказывается, что возможность наследовать поведение гораздо важнее, чем возможность наследовать структуру, и наследование обоих вызывает значительные трудности в традиционных языках объектно-ориентированного программирования. Другие высокоуровневые аспекты системы типов Julia, которые следует упомянуть заранее, это:
- Нет разделения между объектными и не объектными значениями: все значения в Julia являются истинными объектами, имеющими тип, который принадлежит единой, полностью связанной графу типов, все узлы которого являются равноправными первоклассными типами.
- Нет значимого понятия "тип времени компиляции": единственный тип, который имеет значение, - это его фактический тип во время выполнения программы. Это называется "тип времени выполнения" в объектно-ориентированных языках, где сочетание статической компиляции с полиморфизмом делает это различие значительным.
- Только значения, а не переменные, имеют типы – переменные просто имена, связанные со значениями, хотя для простоты мы можем сказать "тип переменной" как сокращение для "тип значения, на которое ссылается переменная".
- Как абстрактные, так и конкретные типы могут быть параметризованы другими типами. Они также могут быть параметризованы символами, значениями любого типа, для которых
isbits
возвращает true (по сути, такие вещи, как числа и булевы значения, которые хранятся как типы C илиstruct
, без указателей на другие объекты), а также кортежами этих значений. Параметры типа могут быть опущены, когда они не нуждаются в ссылке или ограничении.
Типовая система Julia разработана так, чтобы быть мощной и выразительной, но при этом ясной, интуитивно понятной и ненавязчивой. Многие программисты на Julia, возможно, никогда не почувствуют необходимости писать код, который явно использует типы. Однако некоторые виды программирования становятся более ясными, простыми, быстрыми и надежными с объявленными типами.
Type Declarations
Оператор ::
может быть использован для присоединения аннотаций типов к выражениям и переменным в программах. Существует две основные причины для этого:
- В качестве утверждения, чтобы помочь подтвердить, что ваша программа работает так, как вы ожидаете, и
- Чтобы предоставить дополнительную информацию о типах компилятору, что затем может улучшить производительность в некоторых случаях.
Когда оператор ::
добавляется к выражению, вычисляющему значение, он читается как "является экземпляром". Его можно использовать в любом месте, чтобы утверждать, что значение выражения слева является экземпляром типа справа. Когда тип справа конкретный, значение слева должно иметь этот тип в качестве своей реализации – помните, что все конкретные типы являются финальными, поэтому ни одна реализация не является подтипом другой. Когда тип абстрактный, достаточно, чтобы значение было реализовано конкретным типом, который является подтипом абстрактного типа. Если утверждение типа неверно, выбрасывается исключение, в противном случае возвращается значение слева:
julia> (1+2)::AbstractFloat
ERROR: TypeError: in typeassert, expected AbstractFloat, got a value of type Int64
julia> (1+2)::Int
3
Это позволяет прикрепить утверждение типа к любому выражению на месте.
Когда добавляется к переменной с левой стороны присваивания или как часть объявления local
, оператор ::
означает немного другое: он объявляет, что переменная всегда должна иметь указанный тип, как объявление типа в статически типизированном языке, таком как C. Каждое значение, присвоенное переменной, будет преобразовано в объявленный тип с использованием convert
:
julia> function foo()
x::Int8 = 100
x
end
foo (generic function with 1 method)
julia> x = foo()
100
julia> typeof(x)
Int8
Эта функция полезна для избежания "подводных камней" производительности, которые могут возникнуть, если одно из присваиваний переменной неожиданно изменит её тип.
Это поведение "декларации" происходит только в определенных контекстах:
local x::Int8 # in a local declaration
x::Int8 = 10 # as the left-hand side of an assignment
и применяется ко всей текущей области видимости, даже до объявления.
Начиная с Julia 1.8, объявления типов теперь могут использоваться в глобальной области, т.е. аннотации типов могут быть добавлены к глобальным переменным, чтобы сделать доступ к ним типобезопасным.
julia> x::Int = 10
10
julia> x = 3.5
ERROR: InexactError: Int64(3.5)
julia> function foo(y)
global x = 15.8 # throws an error when foo is called
return x + y
end
foo (generic function with 1 method)
julia> foo(10)
ERROR: InexactError: Int64(15.8)
Объявления также могут быть прикреплены к определениям функций:
function sinc(x)::Float64
if x == 0
return 1
end
return sin(pi*x)/(pi*x)
end
Возврат из этой функции ведет себя так же, как присваивание переменной с объявленным типом: значение всегда преобразуется в Float64
.
Abstract Types
Абстрактные типы не могут быть инстанцированы и служат только в качестве узлов в графе типов, описывая наборы связанных конкретных типов: тех конкретных типов, которые являются их потомками. Мы начинаем с абстрактных типов, даже несмотря на то, что у них нет инстанцирования, потому что они являются основой системы типов: они формируют концептуальную иерархию, которая делает систему типов Julia более чем просто коллекцией реализаций объектов.
Напоминаем, что в Integers and Floating-Point Numbers мы представили различные конкретные типы числовых значений: Int8
, UInt8
, Int16
, UInt16
, Int32
, UInt32
, Int64
, UInt64
, Int128
, UInt128
, Float16
, Float32
, и Float64
. Хотя у них разные размеры представления, Int8
, Int16
, Int32
, Int64
и Int128
объединяет то, что они являются знаковыми целочисленными типами. Аналогично, UInt8
, UInt16
, UInt32
, UInt64
и UInt128
являются беззнаковыми целочисленными типами, в то время как Float16
, Float32
и Float64
отличаются тем, что являются типами с плавающей запятой, а не целыми числами. Обычно код имеет смысл, например, только если его аргументы представляют собой какой-то вид целого числа, но не зависит от того, какой именно вид целого числа. Например, алгоритм нахождения наибольшего общего делителя работает для всех видов целых чисел, но не будет работать для чисел с плавающей запятой. Абстрактные типы позволяют строить иерархию типов, предоставляя контекст, в который могут вписываться конкретные типы. Это позволяет вам, например, легко программировать для любого типа, который является целым числом, не ограничивая алгоритм конкретным типом целого числа.
Абстрактные типы объявляются с использованием ключевого слова abstract type
. Общие синтаксисы для объявления абстрактного типа следующие:
abstract type «name» end
abstract type «name» <: «supertype» end
Ключевое слово abstract type
вводит новый абстрактный тип, имя которого задается «name»
. Это имя может быть дополнительно сопровождаться <:
и уже существующим типом, указывая на то, что вновь объявленный абстрактный тип является подтипом этого "родительского" типа.
Когда не указан суперкласс, суперкласс по умолчанию — это Any
— предопределенный абстрактный тип, экземплярами которого являются все объекты, а все типы являются подтипами. В теории типов Any
обычно называют "верхом", потому что он находится на вершине графа типов. В Julia также есть предопределенный абстрактный тип "низ", находящийся на дне графа типов, который записывается как Union{}
. Он является точной противоположностью Any
: ни один объект не является экземпляром Union{}
, и все типы являются суперклассами Union{}
.
Давайте рассмотрим некоторые из абстрактных типов, которые составляют числовую иерархию Julia:
abstract type Number end
abstract type Real <: Number end
abstract type AbstractFloat <: Real end
abstract type Integer <: Real end
abstract type Signed <: Integer end
abstract type Unsigned <: Integer end
Тип Number
является прямым дочерним типом Any
, а Real
является его дочерним типом. В свою очередь, Real
имеет два дочерних типа (у него больше, но здесь показаны только два; к остальным мы вернемся позже): Integer
и AbstractFloat
, разделяющие мир на представления целых чисел и представления действительных чисел. Представления действительных чисел включают типы с плавающей запятой, но также включают и другие типы, такие как рациональные. AbstractFloat
включает только представления действительных чисел с плавающей запятой. Целые числа дополнительно подразделяются на Signed
и Unsigned
разновидности.
Оператор <:
в общем смысле означает "является подтипом", и, используемый в объявлениях, подобных приведенным выше, объявляет тип справа непосредственным суперклассом вновь объявленного типа. Он также может использоваться в выражениях как оператор подтипа, который возвращает true
, когда его левый операнд является подтипом его правого операнда:
julia> Integer <: Number
true
julia> Integer <: AbstractFloat
false
Важное применение абстрактных типов заключается в предоставлении стандартных реализаций для конкретных типов. Чтобы привести простой пример, рассмотрим:
function myplus(x,y)
x+y
end
Первое, что следует отметить, это то, что вышеуказанные объявления аргументов эквивалентны x::Any
и y::Any
. Когда эта функция вызывается, скажем, как myplus(2,5)
, диспетчер выбирает наиболее специфичный метод с именем myplus
, который соответствует данным аргументам. (См. Methods для получения дополнительной информации о множественном диспетче.)
Предполагая, что не найдено более специфичного метода, чем указанный выше, Julia затем внутренне определяет и компилирует метод, называемый myplus
, специально для двух аргументов типа Int
, основываясь на приведенной выше обобщенной функции, т.е. она неявно определяет и компилирует:
function myplus(x::Int,y::Int)
x+y
end
и, наконец, он вызывает этот конкретный метод.
Таким образом, абстрактные типы позволяют программистам писать обобщенные функции, которые затем могут использоваться в качестве метода по умолчанию для многих комбинаций конкретных типов. Благодаря множественному диспетчеризации программист имеет полный контроль над тем, используется ли метод по умолчанию или более специфичный метод.
Важно отметить, что нет потери производительности, если программист полагается на функцию, аргументы которой являются абстрактными типами, потому что она перекомпилируется для каждого кортежа конкретных типов аргументов, с которыми она вызывается. (Тем не менее, может возникнуть проблема с производительностью в случае аргументов функции, которые являются контейнерами абстрактных типов; см. Performance Tips.)
Primitive Types
Практически всегда предпочтительнее обернуть существующий примитивный тип в новый составной тип, чем определять свой собственный примитивный тип.
Эта функциональность существует, чтобы позволить Julia загружать стандартные примитивные типы, которые поддерживает LLVM. Как только они определены, очень мало причин для определения большего количества.
Примитивный тип — это конкретный тип, данные которого состоят из простых битов. Классическими примерами примитивных типов являются целые числа и числа с плавающей запятой. В отличие от большинства языков, Julia позволяет вам объявлять свои собственные примитивные типы, а не предоставляет только фиксированный набор встроенных. На самом деле стандартные примитивные типы все определены в самом языке:
primitive type Float16 <: AbstractFloat 16 end
primitive type Float32 <: AbstractFloat 32 end
primitive type Float64 <: AbstractFloat 64 end
primitive type Bool <: Integer 8 end
primitive type Char <: AbstractChar 32 end
primitive type Int8 <: Signed 8 end
primitive type UInt8 <: Unsigned 8 end
primitive type Int16 <: Signed 16 end
primitive type UInt16 <: Unsigned 16 end
primitive type Int32 <: Signed 32 end
primitive type UInt32 <: Unsigned 32 end
primitive type Int64 <: Signed 64 end
primitive type UInt64 <: Unsigned 64 end
primitive type Int128 <: Signed 128 end
primitive type UInt128 <: Unsigned 128 end
Общие синтаксисы для объявления примитивного типа следующие:
primitive type «name» «bits» end
primitive type «name» <: «supertype» «bits» end
Количество бит указывает, сколько памяти требуется для типа, а имя дает новому типу название. Примитивный тип может быть объявлен как подтип некоторого супертайпа. Если супертайп опущен, то тип по умолчанию имеет Any
в качестве своего непосредственного супертайпа. Таким образом, объявление Bool
выше означает, что булевое значение занимает восемь бит для хранения и имеет Integer
в качестве своего непосредственного супертайпа. В настоящее время поддерживаются только размеры, которые являются кратными 8 битам, и вы, вероятно, столкнетесь с ошибками LLVM с размерами, отличными от указанных выше. Поэтому булевы значения, хотя им действительно нужен всего лишь один бит, не могут быть объявлены меньше восьми бит.
Типы Bool
, Int8
и UInt8
имеют идентичные представления: это восьмибитные куски памяти. Однако, поскольку система типов Julia является номинативной, они не взаимозаменяемы, несмотря на идентичную структуру. Фундаментальное различие между ними заключается в том, что у них разные суперклассы: прямой суперкласс 4d61726b646f776e2e436f64652822222c2022426f6f6c2229_40726566
— это Integer
, суперкласс 4d61726b646f776e2e436f64652822222c2022496e74382229_40726566
— это Signed
, а суперкласс 4d61726b646f776e2e436f64652822222c202255496e74382229_40726566
— это Unsigned
. Все остальные различия между 4d61726b646f776e2e436f64652822222c2022426f6f6c2229_40726566
, 4d61726b646f776e2e436f64652822222c2022496e74382229_40726566
и 4d61726b646f776e2e436f64652822222c202255496e74382229_40726566
касаются поведения — того, как функции определены для работы с объектами этих типов в качестве аргументов. Вот почему номинативная система типов необходима: если бы структура определяла тип, который, в свою очередь, диктует поведение, то было бы невозможно заставить 4d61726b646f776e2e436f64652822222c2022426f6f6c2229_40726566
вести себя иначе, чем 4d61726b646f776e2e436f64652822222c2022496e74382229_40726566
или 4d61726b646f776e2e436f64652822222c202255496e74382229_40726566
.
Composite Types
Composite types называются записями, структурами или объектами в различных языках. Композитный тип — это коллекция именованных полей, экземпляр которой может рассматриваться как одно значение. Во многих языках композитные типы являются единственным видом пользовательского определяемого типа, и они, безусловно, являются наиболее часто используемым пользовательским определяемым типом и в Julia.
В основных объектно-ориентированных языках, таких как C++, Java, Python и Ruby, составные типы также имеют связанные с ними именованные функции, и это сочетание называется "объектом". В более чистых объектно-ориентированных языках, таких как Ruby или Smalltalk, все значения являются объектами, независимо от того, являются ли они составными или нет. В менее чистых объектно-ориентированных языках, включая C++ и Java, некоторые значения, такие как целые числа и числа с плавающей запятой, не являются объектами, в то время как экземпляры пользовательских составных типов являются истинными объектами с связанными методами. В Julia все значения являются объектами, но функции не связаны с объектами, над которыми они работают. Это необходимо, поскольку Julia выбирает, какой метод функции использовать, с помощью множественной диспетчеризации, что означает, что типы всех аргументов функции учитываются при выборе метода, а не только первого (см. Methods для получения дополнительной информации о методах и диспетчеризации). Таким образом, было бы неуместно, чтобы функции "принадлежали" только своему первому аргументу. Организация методов в объекты функций, а не наличие именованных наборов методов "внутри" каждого объекта оказывается весьма полезным аспектом проектирования языка.
Составные типы вводятся с помощью ключевого слова struct
, за которым следует блок имен полей, при необходимости аннотированных типами с использованием оператора ::
:
julia> struct Foo
bar
baz::Int
qux::Float64
end
Поля без аннотации типа по умолчанию имеют тип Any
и, соответственно, могут содержать любое значение.
Новые объекты типа Foo
создаются путем применения объекта типа Foo
как функции к значениям для его полей:
julia> foo = Foo("Hello, world.", 23, 1.5)
Foo("Hello, world.", 23, 1.5)
julia> typeof(foo)
Foo
Когда тип применяется как функция, это называется конструктор. Два конструктора генерируются автоматически (они называются конструкторами по умолчанию). Один принимает любые аргументы и вызывает convert
, чтобы преобразовать их в типы полей, а другой принимает аргументы, которые точно соответствуют типам полей. Причина, по которой оба этих конструктора генерируются, заключается в том, что это упрощает добавление новых определений без случайной замены конструктора по умолчанию.
Поскольку поле bar
не имеет ограничений по типу, подойдет любое значение. Однако значение для baz
должно быть преобразуемым в Int
:
julia> Foo((), 23.5, 1)
ERROR: InexactError: Int64(23.5)
Stacktrace:
[...]
Вы можете найти список имен полей, используя функцию fieldnames
.
julia> fieldnames(Foo)
(:bar, :baz, :qux)
Вы можете получить доступ к значениям полей составного объекта, используя традиционную нотацию foo.bar
:
julia> foo.bar
"Hello, world."
julia> foo.baz
23
julia> foo.qux
1.5
Объекты-композиты, объявленные с помощью struct
, являются неизменяемыми; их нельзя изменять после создания. Это может показаться странным на первый взгляд, но у этого есть несколько преимуществ:
- Это может быть более эффективно. Некоторые структуры могут быть эффективно упакованы в массивы, и в некоторых случаях компилятор может избежать выделения неизменяемых объектов полностью.
- Невозможно нарушить инварианты, предоставленные конструкторами типа.
- Код, использующий неизменяемые объекты, может быть проще для понимания.
Неизменяемый объект может содержать изменяемые объекты, такие как массивы, в качестве полей. Эти содержащиеся объекты останутся изменяемыми; только поля самого неизменяемого объекта не могут быть изменены, чтобы указывать на другие объекты.
Где это необходимо, изменяемые составные объекты могут быть объявлены с помощью ключевого слова mutable struct
, что будет обсуждено в следующем разделе.
Если все поля неизменяемой структуры неразличимы (===
), то два неизменяемых значения, содержащих эти поля, также неразличимы:
julia> struct X
a::Int
b::Float64
end
julia> X(1, 2) === X(1, 2)
true
Существует гораздо больше информации о том, как создаются экземпляры составных типов, но это обсуждение зависит как от Parametric Types, так и от Methods, и достаточно важно, чтобы быть рассмотренным в отдельном разделе: Constructors.
Для многих пользовательских типов X
вы можете захотеть определить метод Base.broadcastable(x::X) = Ref(x)
, чтобы экземпляры этого типа действовали как 0-мерные "скаляры" для broadcasting.
Mutable Composite Types
Если составной тип объявлен с помощью mutable struct
вместо struct
, то его экземпляры могут быть изменены:
julia> mutable struct Bar
baz
qux::Float64
end
julia> bar = Bar("Hello", 1.5);
julia> bar.qux = 2.0
2.0
julia> bar.baz = 1//2
1//2
Дополнительный интерфейс между полями и пользователем может быть предоставлен через Instance Properties. Это предоставляет больше контроля над тем, что можно получить и изменить с помощью нотации bar.baz
.
Чтобы поддерживать мутацию, такие объекты обычно выделяются в куче и имеют стабильные адреса в памяти. Изменяемый объект похож на маленький контейнер, который может содержать разные значения с течением времени, и поэтому его можно надежно идентифицировать только по адресу. В отличие от этого, экземпляр неизменяемого типа ассоциирован с конкретными значениями полей – значения полей сами по себе говорят вам все о объекте. При решении о том, следует ли сделать тип изменяемым, задайте вопрос, будут ли два экземпляра с одинаковыми значениями полей считаться идентичными, или они могут изменяться независимо с течением времени. Если они будут считаться идентичными, тип, вероятно, должен быть неизменяемым.
Чтобы подвести итог, два основных свойства определяют неизменяемость в Julia:
Не разрешается изменять значение неизменяемого типа.
- Для типов битов это означает, что битовый шаблон значения, однажды установленного, никогда не изменится, и это значение является идентичностью типа битов.
- Для составных типов это означает, что идентичность значений их полей никогда не изменится. Когда поля являются типами битов, это означает, что их биты никогда не изменятся, для полей, значения которых являются изменяемыми типами, такими как массивы, это означает, что поля всегда будут ссылаться на одно и то же изменяемое значение, даже если содержимое этого изменяемого значения может быть изменено.
Объект с неизменяемым типом может быть свободно скопирован компилятором, поскольку его неизменяемость делает невозможным программное различие между оригинальным объектом и копией.
- В частности, это означает, что достаточно маленькие неизменяемые значения, такие как целые числа и числа с плавающей запятой, обычно передаются в функции через регистры (или выделяются в стеке).
- Изменяемые значения, с другой стороны, выделяются в куче и передаются функциям в виде указателей на значения, выделенные в куче, за исключением случаев, когда компилятор уверен, что нет способа определить, что это не так.
В случаях, когда одно или несколько полей изменяемой структуры известно как неизменяемые, можно объявить эти поля как таковые, используя const
, как показано ниже. Это позволяет использовать некоторые, но не все оптимизации неизменяемых структур и может быть использовано для обеспечения инвариантов для конкретных полей, помеченных как const
.
const
аннотирование полей изменяемых структур требует как минимум Julia 1.8.
julia> mutable struct Baz
a::Int
const b::Float64
end
julia> baz = Baz(1, 1.5);
julia> baz.a = 2
2
julia> baz.b = 2.0
ERROR: setfield!: const field .b of type Baz cannot be changed
[...]
Declared Types
Три вида типов (абстрактные, примитивные, составные), обсуждаемые в предыдущих разделах, на самом деле все тесно связаны. Они имеют одинаковые ключевые свойства:
- Они явно объявлены.
- У них есть имена.
- Они явно объявили суперклассы.
- Они могут иметь параметры.
Из-за этих общих свойств эти типы внутренне представлены как экземпляры одного и того же концепта, DataType
, который является типом любого из этих типов:
julia> typeof(Real)
DataType
julia> typeof(Int)
DataType
DataType
может быть абстрактным или конкретным. Если он конкретный, у него есть заданный размер, структура хранения и (по желанию) имена полей. Таким образом, примитивный тип — это DataType
с ненулевым размером, но без имен полей. Композитный тип — это DataType
, который имеет имена полей или пуст (нулевой размер).
Каждое конкретное значение в системе является экземпляром какого-то DataType
.
Type Unions
Тип объединения — это специальный абстрактный тип, который включает в себя в качестве объектов все экземпляры любых из его аргументных типов, созданных с использованием специального Union
ключевого слова:
julia> IntOrString = Union{Int,AbstractString}
Union{Int64, AbstractString}
julia> 1 :: IntOrString
1
julia> "Hello!" :: IntOrString
"Hello!"
julia> 1.0 :: IntOrString
ERROR: TypeError: in typeassert, expected Union{Int64, AbstractString}, got a value of type Float64
Компиляторы для многих языков имеют внутреннюю конструкцию объединения для рассуждений о типах; Julia просто предоставляет ее программисту. Компилятор Julia способен генерировать эффективный код в присутствии типов Union
с небольшим количеством типов [1], генерируя специализированный код в отдельных ветвях для каждого возможного типа.
Особенно полезным случаем типа Union
является Union{T, Nothing}
, где T
может быть любым типом, а Nothing
— это синглетон тип, единственным экземпляром которого является объект nothing
. Этот паттерн является эквивалентом Nullable
, Option
or Maybe
типов в других языках. Объявление аргумента функции или поля как Union{T, Nothing}
позволяет установить его либо в значение типа T
, либо в nothing
, чтобы указать, что значения нет. См. this FAQ entry для получения дополнительной информации.
Parametric Types
Важной и мощной особенностью системы типов Julia является то, что она параметрическая: типы могут принимать параметры, так что объявления типов фактически вводят целое семейство новых типов – по одному для каждой возможной комбинации значений параметров. Существует множество языков, которые поддерживают какую-то версию generic programming, в которых структуры данных и алгоритмы для их обработки могут быть указаны без указания точных типов. Например, некоторый вид обобщенного программирования существует в ML, Haskell, Ada, Eiffel, C++, Java, C#, F# и Scala, чтобы назвать лишь некоторые. Некоторые из этих языков поддерживают истинный параметрический полиморфизм (например, ML, Haskell, Scala), в то время как другие поддерживают эклектичные, основанные на шаблонах стили обобщенного программирования (например, C++, Java). С таким множеством различных разновидностей обобщенного программирования и параметрических типов в различных языках, мы даже не будем пытаться сравнивать параметрические типы Julia с другими языками, а вместо этого сосредоточимся на объяснении системы Julia как таковой. Мы отметим, однако, что поскольку Julia является языком с динамической типизацией и не требует принятия всех решений о типах на этапе компиляции, многие традиционные трудности, встречающиеся в статических параметрических системах типов, могут быть относительно легко решены.
Все объявленные типы (разновидности DataType
) могут быть параметризованы, с одинаковым синтаксисом в каждом случае. Мы обсудим их в следующем порядке: сначала параметрические составные типы, затем параметрические абстрактные типы и, наконец, параметрические примитивные типы.
Parametric Composite Types
Параметры типа вводятся сразу после имени типа, окруженные фигурными скобками:
julia> struct Point{T}
x::T
y::T
end
Это объявление определяет новый параметрический тип, Point{T}
, который содержит две "координаты" типа T
. Что же, можно спросить, такое T
? Ну, это как раз и есть суть параметрических типов: это может быть любой тип (или значение любого битового типа, на самом деле, хотя здесь он явно используется как тип). Point{Float64}
— это конкретный тип, эквивалентный типу, определенному заменой T
в определении Point
на Float64
. Таким образом, это одно объявление на самом деле объявляет неограниченное количество типов: Point{Float64}
, Point{AbstractString}
, Point{Int64}
и т.д. Каждый из этих типов теперь является используемым конкретным типом:
julia> Point{Float64}
Point{Float64}
julia> Point{AbstractString}
Point{AbstractString}
Тип Point{Float64}
— это точка, координаты которой являются 64-битными числами с плавающей запятой, в то время как тип Point{AbstractString}
— это "точка", координаты которой представляют собой строковые объекты (см. Strings).
Point
сам по себе также является допустимым объектом типа, содержащим все экземпляры Point{Float64}
, Point{AbstractString}
и т. д. в качестве подтипов:
julia> Point{Float64} <: Point
true
julia> Point{AbstractString} <: Point
true
Другие типы, конечно, не являются подтипами этого:
julia> Float64 <: Point
false
julia> AbstractString <: Point
false
Конкретные типы Point
с разными значениями T
никогда не являются подтипами друг друга:
julia> Point{Float64} <: Point{Int64}
false
julia> Point{Float64} <: Point{Real}
false
Этот последний пункт очень важен: хотя Float64 <: Real
, мы НЕ имеем Point{Float64} <: Point{Real}
.
Другими словами, в терминах теории типов, параметры типов Julia являются инвариантными, а не covariant (or even contravariant). Это связано с практическими причинами: хотя любой экземпляр Point{Float64}
концептуально может быть похож на экземпляр Point{Real}
, эти два типа имеют разные представления в памяти:
- Экземпляр
Point{Float64}
может быть компактно и эффективно представлен в виде немедленной пары 64-битных значений; - Экземпляр
Point{Real}
должен быть способен хранить любую пару экземпляровReal
. Поскольку объекты, которые являются экземплярамиReal
, могут иметь произвольный размер и структуру, на практике экземплярPoint{Real}
должен быть представлен как пара указателей на индивидуально выделенные объектыReal
.
Эффективность, полученная благодаря возможности хранить объекты Point{Float64}
с немедленными значениями, значительно увеличивается в случае массивов: Array{Float64}
может храниться как непрерывный блок памяти из 64-битных чисел с плавающей запятой, в то время как Array{Real}
должен быть массивом указателей на индивидуально выделенные Real
объекты – которые могут быть boxed 64-битными числами с плавающей запятой, но также могут быть произвольно большими, сложными объектами, которые объявлены реализациями абстрактного типа Real
.
Поскольку Point{Float64}
не является подтипом Point{Real}
, следующий метод не может быть применен к аргументам типа Point{Float64}
:
function norm(p::Point{Real})
sqrt(p.x^2 + p.y^2)
end
Правильный способ определить метод, который принимает все аргументы типа Point{T}
, где T
является подтипом Real
, это:
function norm(p::Point{<:Real})
sqrt(p.x^2 + p.y^2)
end
(Эквивалентно, можно определить function norm(p::Point{T} where T<:Real)
или function norm(p::Point{T}) where T<:Real
; см. UnionAll Types.)
Более примеров будет обсуждено позже в Methods.
Как создать объект Point
? Возможно определить пользовательские конструкторы для составных типов, которые будут подробно обсуждены в Constructors, но при отсутствии каких-либо специальных объявлений конструкторов есть два стандартных способа создания новых составных объектов: один, в котором параметры типа явно указаны, и другой, в котором они подразумеваются аргументами конструктора объекта.
Поскольку тип Point{Float64}
является конкретным типом, эквивалентным Point
, объявленному с Float64
вместо T
, его можно использовать в качестве конструктора соответствующим образом:
julia> p = Point{Float64}(1.0, 2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p)
Point{Float64}
Для конструктора по умолчанию необходимо предоставить ровно один аргумент для каждого поля:
julia> Point{Float64}(1.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
julia> Point{Float64}(1.0, 2.0, 3.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64, ::Float64, ::Float64)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
Для параметрических типов генерируется только один конструктор по умолчанию, так как переопределить его невозможно. Этот конструктор принимает любые аргументы и преобразует их в типы полей.
Во многих случаях избыточно указывать тип объекта Point
, который вы хотите создать, поскольку типы аргументов вызова конструктора уже неявно предоставляют информацию о типе. По этой причине вы также можете использовать Point
в качестве конструктора, при условии, что подразумеваемое значение типа параметра T
однозначно:
julia> p1 = Point(1.0,2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p1)
Point{Float64}
julia> p2 = Point(1,2)
Point{Int64}(1, 2)
julia> typeof(p2)
Point{Int64}
В случае Point
тип T
однозначно подразумевается только в том случае, если два аргумента Point
имеют один и тот же тип. Когда это не так, конструктор завершится с ошибкой MethodError
:
julia> Point(1,2.5)
ERROR: MethodError: no method matching Point(::Int64, ::Float64)
The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it.
Closest candidates are:
Point(::T, !Matched::T) where T
@ Main none:2
Stacktrace:
[...]
Методы конструктора, которые могут адекватно обрабатывать такие смешанные случаи, могут быть определены, но это не будет обсуждено до более позднего времени в Constructors.
Parametric Abstract Types
Параметрические объявления абстрактных типов объявляют коллекцию абстрактных типов, аналогично:
julia> abstract type Pointy{T} end
С этим объявлением Pointy{T}
является отдельным абстрактным типом для каждого типа или целочисленного значения T
. Как и с параметрическими составными типами, каждый такой экземпляр является подтипом Pointy
:
julia> Pointy{Int64} <: Pointy
true
julia> Pointy{1} <: Pointy
true
Параметрические абстрактные типы инвариантны, так же как и параметрические составные типы:
julia> Pointy{Float64} <: Pointy{Real}
false
julia> Pointy{Real} <: Pointy{Float64}
false
Нотация Pointy{<:Real}
может быть использована для выражения юлиевского аналога ковариантного типа, в то время как Pointy{>:Int}
является аналогом контравариантного типа, но технически они представляют собой множества типов (см. UnionAll Types).
julia> Pointy{Float64} <: Pointy{<:Real}
true
julia> Pointy{Real} <: Pointy{>:Int}
true
Хотя простые абстрактные типы служат для создания полезной иерархии типов над конкретными типами, параметрические абстрактные типы выполняют ту же функцию в отношении параметрических составных типов. Мы могли бы, например, объявить Point{T}
подтипом Pointy{T}
следующим образом:
julia> struct Point{T} <: Pointy{T}
x::T
y::T
end
Учитывая такое объявление, для каждого выбора T
у нас есть Point{T}
как подтип Pointy{T}
:
julia> Point{Float64} <: Pointy{Float64}
true
julia> Point{Real} <: Pointy{Real}
true
julia> Point{AbstractString} <: Pointy{AbstractString}
true
Это отношение также инвариантно:
julia> Point{Float64} <: Pointy{Real}
false
julia> Point{Float64} <: Pointy{<:Real}
true
Какова цель параметрических абстрактных типов, таких как Pointy
? Рассмотрим, если мы создадим реализацию, подобную точке, которая требует только одной координаты, потому что точка находится на диагональной линии x = y:
julia> struct DiagPoint{T} <: Pointy{T}
x::T
end
Теперь как Point{Float64}
, так и DiagPoint{Float64}
являются реализациями абстракции Pointy{Float64}
, и аналогично для каждого другого возможного выбора типа T
. Это позволяет программировать по общему интерфейсу, который разделяют все объекты Pointy
, реализованный как для Point
, так и для DiagPoint
. Однако это не может быть полностью продемонстрировано, пока мы не введем методы и диспетчеризацию в следующем разделе, Methods.
Существуют ситуации, когда может не иметь смысла, чтобы параметры типа свободно варьировались по всем возможным типам. В таких ситуациях можно ограничить диапазон T
следующим образом:
julia> abstract type Pointy{T<:Real} end
С таким объявлением допустимо использовать любой тип, который является подтипом Real
вместо T
, но не типы, которые не являются подтипами Real
:
julia> Pointy{Float64}
Pointy{Float64}
julia> Pointy{Real}
Pointy{Real}
julia> Pointy{AbstractString}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got Type{AbstractString}
julia> Pointy{1}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got a value of type Int64
Параметры типов для параметрических составных типов могут быть ограничены тем же образом:
struct Point{T<:Real} <: Pointy{T}
x::T
y::T
end
Чтобы привести реальный пример того, как вся эта параметрическая типовая механика может быть полезной, вот фактическое определение неизменяемого типа Rational
в Julia (за исключением того, что мы опускаем конструктор здесь для простоты), представляющего собой точное соотношение целых чисел:
struct Rational{T<:Integer} <: Real
num::T
den::T
end
Это имеет смысл только для соотношений целых значений, поэтому тип параметра T
ограничен подтипом Integer
, и соотношение целых чисел представляет значение на действительной числовой прямой, поэтому любое Rational
является экземпляром абстракции Real
.
Tuple Types
Кортежи являются абстракцией аргументов функции – без самой функции. Основные аспекты аргументов функции – это их порядок и их типы. Поэтому тип кортежа похож на параметризованный неизменяемый тип, где каждый параметр является типом одного поля. Например, тип кортежа из 2 элементов напоминает следующий неизменяемый тип:
struct Tuple2{A,B}
a::A
b::B
end
Однако есть три ключевых различия:
- Типы кортежей могут иметь любое количество параметров.
- Типы кортежей являются ковариантными в своих параметрах:
Tuple{Int}
является подтипомTuple{Any}
. ПоэтомуTuple{Any}
считается абстрактным типом, а типы кортежей являются конкретными только если их параметры таковы. - Кортежи не имеют имен полей; поля доступны только по индексу.
Значения кортежа записываются в круглых скобках и разделяются запятыми. Когда кортеж создается, соответствующий тип кортежа генерируется по мере необходимости:
julia> typeof((1,"foo",2.5))
Tuple{Int64, String, Float64}
Обратите внимание на последствия ковариации:
julia> Tuple{Int,AbstractString} <: Tuple{Real,Any}
true
julia> Tuple{Int,AbstractString} <: Tuple{Real,Real}
false
julia> Tuple{Int,AbstractString} <: Tuple{Real,}
false
Интуитивно, это соответствует тому, что тип аргументов функции является подтипом сигнатуры функции (когда сигнатура совпадает).
Vararg Tuple Types
Последний параметр типа кортежа может быть специальным значением Vararg
, которое обозначает любое количество завершающих элементов:
julia> mytupletype = Tuple{AbstractString,Vararg{Int}}
Tuple{AbstractString, Vararg{Int64}}
julia> isa(("1",), mytupletype)
true
julia> isa(("1",1), mytupletype)
true
julia> isa(("1",1,2), mytupletype)
true
julia> isa(("1",1,2,3.0), mytupletype)
false
Кроме того, Vararg{T}
соответствует нулю или более элементам типа T
. Кортежи типов Vararg используются для представления аргументов, принимаемых методами varargs (см. Varargs Functions).
Специальное значение Vararg{T,N}
(когда используется в качестве последнего параметра типа кортежа) соответствует ровно N
элементам типа T
. NTuple{N,T}
является удобным псевдонимом для Tuple{Vararg{T,N}}
, т.е. типа кортежа, содержащего ровно N
элементов типа T
.
Named Tuple Types
Именованные кортежи являются экземплярами типа NamedTuple
, который имеет два параметра: кортеж символов, задающий имена полей, и кортеж типов, задающий типы полей. Для удобства типы NamedTuple
выводятся с использованием макроса @NamedTuple
, который предоставляет удобный синтаксис, похожий на struct
, для объявления этих типов через декларации key::Type
, где опущенный ::Type
соответствует ::Any
.
julia> typeof((a=1,b="hello")) # prints in macro form
@NamedTuple{a::Int64, b::String}
julia> NamedTuple{(:a, :b), Tuple{Int64, String}} # long form of the type
@NamedTuple{a::Int64, b::String}
Форма begin ... end
макроса @NamedTuple
позволяет разделять объявления на несколько строк (аналогично объявлению структуры), но в остальном эквивалентна:
julia> @NamedTuple begin
a::Int
b::String
end
@NamedTuple{a::Int64, b::String}
Тип NamedTuple
может использоваться в качестве конструктора, принимая один аргумент в виде кортежа. Конструируемый тип NamedTuple
может быть либо конкретным типом, с указанными обоими параметрами, либо типом, который указывает только имена полей:
julia> @NamedTuple{a::Float32,b::String}((1, ""))
(a = 1.0f0, b = "")
julia> NamedTuple{(:a, :b)}((1, ""))
(a = 1, b = "")
Если указаны типы полей, аргументы преобразуются. В противном случае используются типы аргументов напрямую.
Parametric Primitive Types
Примитивные типы также могут быть объявлены параметрически. Например, указатели представлены как примитивные типы, которые будут объявлены в Julia следующим образом:
# 32-bit system:
primitive type Ptr{T} 32 end
# 64-bit system:
primitive type Ptr{T} 64 end
Слегка странная особенность этих объявлений по сравнению с типичными параметрическими составными типами заключается в том, что параметр типа T
не используется в определении самого типа – это просто абстрактная метка, по сути определяющая целое семейство типов с идентичной структурой, различающихся только по своему параметру типа. Таким образом, Ptr{Float64}
и Ptr{Int64}
являются различными типами, хотя у них идентичные представления. И, конечно, все конкретные типы указателей являются подтипами обобщенного типа Ptr
:
julia> Ptr{Float64} <: Ptr
true
julia> Ptr{Int64} <: Ptr
true
UnionAll Types
Мы сказали, что параметрический тип, такой как Ptr
, действует как суперкласс всех его экземпляров (Ptr{Int64}
и т.д.). Как это работает? Ptr
сам по себе не может быть обычным типом данных, поскольку без знания типа ссылаемых данных этот тип явно не может быть использован для операций с памятью. Ответ заключается в том, что Ptr
(или другие параметрические типы, такие как Array
) является другим видом типа, называемым UnionAll
типом. Такой тип выражает итеративное объединение типов для всех значений некоторого параметра.
Типы UnionAll
обычно записываются с использованием ключевого слова where
. Например, Ptr
можно более точно записать как Ptr{T} where T
, что означает все значения, тип которых Ptr{T}
для некоторого значения T
. В этом контексте параметр T
также часто называется "переменной типа", так как он похож на переменную, которая принимает значения типов. Каждое where
вводит одну переменную типа, поэтому эти выражения вложены для типов с несколькими параметрами, например Array{T,N} where N where T
.
Синтаксис применения типа A{B,C}
требует, чтобы A
был типом UnionAll
, и сначала подставляет B
для внешней переменной типа в A
. Ожидается, что результатом будет другой тип UnionAll
, в который затем подставляется C
. Таким образом, A{B,C}
эквивалентно A{B}{C}
. Это объясняет, почему возможно частично инстанцировать тип, как в Array{Float64}
: первое значение параметра зафиксировано, но второе все еще охватывает все возможные значения. Используя явный синтаксис where
, можно зафиксировать любую подмножество параметров. Например, тип всех 1-мерных массивов можно записать как Array{T,1} where T
.
Типы переменных могут быть ограничены отношениями подтипов. Array{T} where T<:Integer
относится ко всем массивам, элементами которых является какой-либо тип Integer
. Синтаксис Array{<:Integer}
является удобным сокращением для Array{T} where T<:Integer
. Типы переменных могут иметь как нижние, так и верхние границы. Array{T} where Int<:T<:Number
относится ко всем массивам Number
, которые могут содержать Int
(поскольку T
должен быть как минимум таким же большим, как Int
). Синтаксис where T>:Int
также работает для указания только нижней границы переменной типа, а Array{>:Int}
эквивалентен Array{T} where T>:Int
.
Поскольку выражения where
вложены, границы переменных типов могут ссылаться на внешние переменные типов. Например, Tuple{T,Array{S}} where S<:AbstractArray{T} where T<:Real
относится к 2-кортежам, первый элемент которых является некоторым Real
, а второй элемент — это Array
любого типа массива, элемент типа которого содержит тип первого элемента кортежа.
Ключевое слово where
само по себе может быть вложено в более сложное объявление. Например, рассмотрим два типа, созданные следующими объявлениями:
julia> const T1 = Array{Array{T, 1} where T, 1}
Vector{Vector} (alias for Array{Array{T, 1} where T, 1})
julia> const T2 = Array{Array{T, 1}, 1} where T
Array{Vector{T}, 1} where T
Тип T1
определяет одномерный массив одномерных массивов; каждый из внутренних массивов состоит из объектов одного и того же типа, но этот тип может различаться от одного внутреннего массива к другому. С другой стороны, тип T2
определяет одномерный массив одномерных массивов, все внутренние массивы которых должны иметь один и тот же тип. Обратите внимание, что T2
является абстрактным типом, например, Array{Array{Int,1},1} <: T2
, в то время как T1
является конкретным типом. В результате T1
может быть сконструирован с помощью конструктора без аргументов a=T1()
, но T2
не может.
Существует удобный синтаксис для именования таких типов, аналогичный краткой форме синтаксиса определения функции:
Vector{T} = Array{T, 1}
Это эквивалентно const Vector = Array{T,1} where T
. Запись Vector{Float64}
эквивалентна записи Array{Float64,1}
, и обобщенный тип Vector
имеет в качестве экземпляров все объекты Array
, где второй параметр – количество измерений массива – равен 1, независимо от того, каков тип элемента. В языках, где параметрические типы всегда должны быть указаны полностью, это не особенно полезно, но в Julia это позволяет писать просто Vector
для абстрактного типа, включая все одномерные плотные массивы любого типа элемента.
Singleton types
Неизменяемые составные типы без полей называются синглтонами. Формально, если
T
является неизменяемым составным типом (т.е. определённым с помощьюstruct
),a является T && b является T
подразумеваетa === b
,
тогда T
является типом-синглтоном.[2] Base.issingletontype
может быть использован для проверки, является ли тип типом-синглтоном. Abstract types не может быть типами-синглтонами по конструкции.
Из определения следует, что может существовать только один экземпляр таких типов:
julia> struct NoFields
end
julia> NoFields() === NoFields()
true
julia> Base.issingletontype(NoFields)
true
Функция ===
подтверждает, что созданные экземпляры NoFields
на самом деле являются одним и тем же.
Параметрические типы могут быть синглтонными типами, когда выполняется вышеуказанное условие. Например,
julia> struct NoFieldsParam{T}
end
julia> Base.issingletontype(NoFieldsParam) # Can't be a singleton type ...
false
julia> NoFieldsParam{Int}() isa NoFieldsParam # ... because it has ...
true
julia> NoFieldsParam{Bool}() isa NoFieldsParam # ... multiple instances.
true
julia> Base.issingletontype(NoFieldsParam{Int}) # Parametrized, it is a singleton.
true
julia> NoFieldsParam{Int}() === NoFieldsParam{Int}()
true
Types of functions
Каждая функция имеет свой собственный тип, который является подтипом Function
.
julia> foo41(x) = x + 1
foo41 (generic function with 1 method)
julia> typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)
Обратите внимание, как typeof(foo41)
выводится как сам себя. Это всего лишь соглашение для вывода, так как это объект первого класса, который можно использовать как любое другое значение:
julia> T = typeof(foo41)
typeof(foo41) (singleton type of function foo41, subtype of Function)
julia> T <: Function
true
Типы функций, определенные на верхнем уровне, являются синглтонами. При необходимости вы можете сравнить их с ===
.
Closures также имеют свой собственный тип, который обычно выводится с именами, заканчивающимися на #<number>
. Имена и типы для функций, определенных в разных местах, различны, но не гарантируется, что они будут выводиться одинаково в разных сессиях.
julia> typeof(x -> x + 1)
var"#9#10"
Типы замыканий не обязательно являются синглтонами.
julia> addy(y) = x -> x + y
addy (generic function with 1 method)
julia> typeof(addy(1)) === typeof(addy(2))
true
julia> addy(1) === addy(2)
false
julia> Base.issingletontype(typeof(addy(1)))
false
Type{T}
type selectors
Для каждого типа T
, Type{T}
является абстрактным параметрическим типом, единственным экземпляром которого является объект T
. Пока мы не обсудим Parametric Methods и conversions, трудно объяснить полезность этой конструкции, но, вкратце, она позволяет специализировать поведение функции для конкретных типов как значений. Это полезно для написания методов (особенно параметрических), поведение которых зависит от типа, который передается в качестве явного аргумента, а не подразумевается по типу одного из его аргументов.
Поскольку определение немного сложно для восприятия, давайте рассмотрим несколько примеров:
julia> isa(Float64, Type{Float64})
true
julia> isa(Real, Type{Float64})
false
julia> isa(Real, Type{Real})
true
julia> isa(Float64, Type{Real})
false
Другими словами, isa(A, Type{B})
истинно тогда и только тогда, когда A
и B
являются одним и тем же объектом, и этот объект является типом.
В частности, поскольку параметрические типы являются invariant, у нас есть
julia> struct TypeParamExample{T}
x::T
end
julia> TypeParamExample isa Type{TypeParamExample}
true
julia> TypeParamExample{Int} isa Type{TypeParamExample}
false
julia> TypeParamExample{Int} isa Type{TypeParamExample{Int}}
true
Без параметра Type
является просто абстрактным типом, который имеет все объекты типов в качестве своих экземпляров:
julia> isa(Type{Float64}, Type)
true
julia> isa(Float64, Type)
true
julia> isa(Real, Type)
true
Любой объект, который не является типом, не является экземпляром Type
:
julia> isa(1, Type)
false
julia> isa("foo", Type)
false
Хотя Type
является частью иерархии типов Julia, как и любой другой абстрактный параметрический тип, он не часто используется вне сигнатур методов, за исключением некоторых особых случаев. Еще одним важным случаем использования Type
является уточнение типов полей, которые в противном случае были бы захвачены менее точно, например, как DataType
в примере ниже, где конструктор по умолчанию может привести к проблемам с производительностью в коде, полагающемся на точный обернутый тип (аналогично abstract type parameters).
julia> struct WrapType{T}
value::T
end
julia> WrapType(Float64) # default constructor, note DataType
WrapType{DataType}(Float64)
julia> WrapType(::Type{T}) where T = WrapType{Type{T}}(T)
WrapType
julia> WrapType(Float64) # sharpened constructor, note more precise Type{Float64}
WrapType{Type{Float64}}(Float64)
Type Aliases
Иногда удобно ввести новое имя для уже выражаемого типа. Это можно сделать с помощью простого оператора присваивания. Например, UInt
является псевдонимом для либо UInt32
, либо UInt64
, в зависимости от размера указателей в системе:
# 32-bit system:
julia> UInt
UInt32
# 64-bit system:
julia> UInt
UInt64
Это достигается с помощью следующего кода в base/boot.jl
:
if Int === Int64
const UInt = UInt64
else
const UInt = UInt32
end
Конечно, это зависит от того, к чему ссылается Int
– но это предопределено как правильный тип – либо Int32
, либо Int64
.
(Обратите внимание, что в отличие от Int
, Float
не существует как псевдоним типа для конкретного размера AbstractFloat
. В отличие от регистров целых чисел, где размер Int
отражает размер нативного указателя на этой машине, размеры регистров с плавающей запятой определяются стандартом IEEE-754.)
Псевдонимы типов могут быть параметризованы:
julia> const Family{T} = Set{T}
Set
julia> Family{Char} === Set{Char}
true
Operations on Types
Поскольку типы в Julia сами по себе являются объектами, обычные функции могут работать с ними. Некоторые функции, которые особенно полезны для работы с типами или их исследования, уже были представлены, такие как оператор <:
, который указывает, является ли его левый операнд подтипом его правого операнда.
Функция isa
проверяет, является ли объект заданного типа, и возвращает true или false:
julia> isa(1, Int)
true
julia> isa(1, AbstractFloat)
false
Функция typeof
, уже использованная в руководстве в примерах, возвращает тип своего аргумента. Поскольку, как было отмечено выше, типы являются объектами, у них также есть типы, и мы можем спросить, каковы их типы:
julia> typeof(Rational{Int})
DataType
julia> typeof(Union{Real,String})
Union
Что если мы повторим процесс? Каков тип типа типа? Как оказывается, типы все являются составными значениями и, следовательно, все имеют тип DataType
:
julia> typeof(DataType)
DataType
julia> typeof(Union)
DataType
DataType
— это собственный тип.
Еще одна операция, которая применяется к некоторым типам, это supertype
, которая раскрывает суперкласс типа. Только объявленные типы (DataType
) имеют однозначные суперклассы:
julia> supertype(Float64)
AbstractFloat
julia> supertype(Number)
Any
julia> supertype(AbstractString)
Any
julia> supertype(Any)
Any
Если вы примените supertype
к другим объектам типа (или не типовым объектам), возникает MethodError
:
julia> supertype(Union{Float64,Int64})
ERROR: MethodError: no method matching supertype(::Type{Union{Float64, Int64}})
The function `supertype` exists, but no method is defined for this combination of argument types.
Closest candidates are:
[...]
Custom pretty-printing
Часто возникает необходимость настроить, как экземпляры типа отображаются. Это достигается перегрузкой функции show
. Например, предположим, что мы определяем тип для представления комплексных чисел в полярной форме:
julia> struct Polar{T<:Real} <: Number
r::T
Θ::T
end
julia> Polar(r::Real,Θ::Real) = Polar(promote(r,Θ)...)
Polar
Здесь мы добавили пользовательскую функцию конструктора, чтобы она могла принимать аргументы различных Real
типов и продвигать их к общему типу (см. Constructors и Conversion and Promotion). (Конечно, нам также придется определить множество других методов, чтобы сделать его похожим на Number
, например, +
, *
, one
, zero
, правила продвижения и так далее.) По умолчанию экземпляры этого типа отображаются довольно просто, с информацией о названии типа и значениях полей, например, Polar{Float64}(3.0,4.0)
.
Если мы хотим, чтобы он отображался как 3.0 * exp(4.0im)
, мы определим следующий метод для вывода объекта в заданный объект вывода io
(представляющий файл, терминал, буфер и т.д.; см. Networking and Streams):
julia> Base.show(io::IO, z::Polar) = print(io, z.r, " * exp(", z.Θ, "im)")
Более детальный контроль над отображением объектов Polar
возможен. В частности, иногда требуется как многократный формат печати с подробным выводом, используемый для отображения одного объекта в REPL и других интерактивных средах, так и более компактный однострочный формат, используемый для print
или для отображения объекта как части другого объекта (например, в массиве). Хотя по умолчанию функция show(io, z)
вызывается в обоих случаях, вы можете определить другой многократный формат для отображения объекта, перегрузив трехаргументную форму show
, которая принимает MIME-тип text/plain
в качестве второго аргумента (см. Multimedia I/O), например:
julia> Base.show(io::IO, ::MIME"text/plain", z::Polar{T}) where{T} =
print(io, "Polar{$T} complex number:\n ", z)
(Обратите внимание, что print(..., z)
здесь вызовет метод show(io, z)
с двумя аргументами.) Это приводит к:
julia> Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> [Polar(3, 4.0), Polar(4.0,5.3)]
2-element Vector{Polar{Float64}}:
3.0 * exp(4.0im)
4.0 * exp(5.3im)
где однострочная форма show(io, z)
все еще используется для массива значений Polar
. Технически, REPL вызывает display(z)
, чтобы отобразить результат выполнения строки, что по умолчанию приводит к show(stdout, MIME("text/plain"), z)
, что, в свою очередь, по умолчанию приводит к show(stdout, z)
, но вы не должны определять новые display
методы, если вы не определяете новый обработчик мультимедийного отображения (см. Multimedia I/O).
Кроме того, вы также можете определить методы show
для других типов MIME, чтобы обеспечить более богатое отображение (HTML, изображения и т.д.) объектов в средах, которые это поддерживают (например, IJulia). Например, мы можем определить форматированное HTML-отображение объектов Polar
с верхними индексами и курсивом с помощью:
julia> Base.show(io::IO, ::MIME"text/html", z::Polar{T}) where {T} =
println(io, "<code>Polar{$T}</code> complex number: ",
z.r, " <i>e</i><sup>", z.Θ, " <i>i</i></sup>")
Объект Polar
затем будет автоматически отображаться с использованием HTML в среде, которая поддерживает отображение HTML, но вы можете вызвать show
вручную, чтобы получить HTML-вывод, если хотите:
julia> show(stdout, "text/html", Polar(3.0,4.0))
<code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup>
An HTML renderer would display this as: Polar{Float64}
complex number: 3.0 e4.0 i
В качестве правила, метод show
в одну строку должен выводить допустимое выражение Julia для создания отображаемого объекта. Когда этот метод show
содержит инфиксные операторы, такие как оператор умножения (*
) в нашем методе show
в одну строку для Polar
выше, он может не распознаваться правильно, когда выводится как часть другого объекта. Чтобы увидеть это, рассмотрим объект выражения (см. Program representation), который возводит в квадрат конкретный экземпляр нашего типа Polar
:
julia> a = Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> print(:($a^2))
3.0 * exp(4.0im) ^ 2
Поскольку оператор ^
имеет более высокий приоритет, чем *
(см. Operator Precedence and Associativity), этот вывод не точно отражает выражение a ^ 2
, которое должно быть равно (3.0 * exp(4.0im)) ^ 2
. Чтобы решить эту проблему, мы должны создать пользовательский метод для Base.show_unquoted(io::IO, z::Polar, indent::Int, precedence::Int)
, который вызывается внутренне объектом выражения при печати:
julia> function Base.show_unquoted(io::IO, z::Polar, ::Int, precedence::Int)
if Base.operator_precedence(:*) <= precedence
print(io, "(")
show(io, z)
print(io, ")")
else
show(io, z)
end
end
julia> :($a^2)
:((3.0 * exp(4.0im)) ^ 2)
Метод, определенный выше, добавляет скобки вокруг вызова show
, когда приоритет оператора вызова выше или равен приоритету умножения. Эта проверка позволяет выражениям, которые правильно разбираются без скобок (таким как :($a + 2)
и :($a == 2)
), опускать их при выводе:
julia> :($a + 2)
:(3.0 * exp(4.0im) + 2)
julia> :($a == 2)
:(3.0 * exp(4.0im) == 2)
В некоторых случаях полезно настраивать поведение методов show
в зависимости от контекста. Это можно сделать с помощью типа IOContext
, который позволяет передавать контекстные свойства вместе с обернутым IO потоком. Например, мы можем построить более короткое представление в нашем методе show
, когда свойство :compact
установлено в true
, возвращаясь к длинному представлению, если свойство false
или отсутствует:
julia> function Base.show(io::IO, z::Polar)
if get(io, :compact, false)::Bool
print(io, z.r, "ℯ", z.Θ, "im")
else
print(io, z.r, " * exp(", z.Θ, "im)")
end
end
Это новое компактное представление будет использоваться, когда переданный поток ввода-вывода является объектом IOContext
с установленным свойством :compact
. В частности, это происходит при печати массивов с несколькими столбцами (где горизонтальное пространство ограничено):
julia> show(IOContext(stdout, :compact=>true), Polar(3, 4.0))
3.0ℯ4.0im
julia> [Polar(3, 4.0) Polar(4.0,5.3)]
1×2 Matrix{Polar{Float64}}:
3.0ℯ4.0im 4.0ℯ5.3im
Смотрите документацию IOContext
для получения списка общих свойств, которые можно использовать для настройки печати.
"Value types"
В Julia вы не можете выполнять диспетчеризацию по значению, такому как true
или false
. Однако вы можете выполнять диспетчеризацию по параметрическим типам, и Julia позволяет вам включать "обычные биты" значения (Типы, Символы, Целые числа, числа с плавающей запятой, кортежи и т. д.) в качестве параметров типа. Общим примером является параметр размерности в Array{T,N}
, где T
— это тип (например, Float64
), но N
— это просто Int
.
Вы можете создавать свои собственные пользовательские типы, которые принимают значения в качестве параметров, и использовать их для управления диспетчеризацией пользовательских типов. В качестве иллюстрации этой идеи давайте введем параметрический тип Val{x}
, и его конструктор Val(x) = Val{x}()
, который служит обычным способом использования этой техники в случаях, когда вам не нужна более сложная иерархия.
Val
определяется как:
julia> struct Val{x}
end
julia> Val(x) = Val{x}()
Val
В реализации Val
нет ничего больше, чем это. Некоторые функции в стандартной библиотеке Julia принимают экземпляры Val
в качестве аргументов, и вы также можете использовать его для написания собственных функций. Например:
julia> firstlast(::Val{true}) = "First"
firstlast (generic function with 1 method)
julia> firstlast(::Val{false}) = "Last"
firstlast (generic function with 2 methods)
julia> firstlast(Val(true))
"First"
julia> firstlast(Val(false))
"Last"
Для согласованности в Julia, место вызова всегда должно передавать экземпляр Val
, а не использовать тип, т.е. используйте foo(Val(:bar))
, а не foo(Val{:bar})
.
Стоит отметить, что крайне легко неправильно использовать параметрические "значения" типов, включая Val
; в неблагоприятных случаях вы можете легко ухудшить производительность вашего кода. В частности, вы никогда не захотите писать фактический код, как показано выше. Для получения дополнительной информации о правильных (и неправильных) способах использования Val
, пожалуйста, прочитайте the more extensive discussion in the performance tips.