Типы
В этой главе я отталкиваюсь от официального руководства (http://docs.julialang.org/en/stable/manual/types/\). Это близко к переводу с сокращениями, с редкими вставками моих комментариев, оформленных курсивом.
Глава о типах (вместе с информацией о функциях и диспетчеризации) - важная часть для понимания всего языка, но иногда не воспринимаемая с первого прочтения. Мне понадобилось прочитать ее с перерывами на практику раза три.
Система типов в Julia характеризуется тремя определениями: динамическая, номинативная и параметрическая. Ниже кратко объясним их суть.
Динамическая: как во многих динамических языках, в Julia типы привязаны к конкретным значениям, а не к переменным.
Номинативная: тип определяется именем (в противоположность системам, где тип идентифицируется в первую очередь его структурой).
Параметрическая: общие типы могут быть параметризованы.
Одна из наиболее отличительных особенностей системы типов Джулии в том, что конкретные типы не могут иметь подтипы: все конкретные типы являются окончательными. Иметь подтипы могут только абстрактные типы.
Авторы языка сошлись во мнении, что важно наследование поведения, и не важно наследование структуры. Хотя обсуждения на тему изменения/дополнения этой системы поднимались пару лет назад (это то, что я видел на github, где разработчики обсуждают подобные вопросы), это в конечном счете не привело до сих пор к какой-то переделке. По моему личному мнению, здесь не помешали бы трейты в стиле Scala (это тоже обсуждалось и, может быть, еще появится), хотя, как я мог убедится на практике, возможность параметризовать типы может удовлетворить многие потребности разработчика.
Некоторы высокоуровневые аспекты, которые следует упомянуть сразу:
- Нет различия между объктом и "не-объектом": все значения - настоящие оъекты, имеющие свой тип.
- Нет понятия "тип врмени компиляции": единственным типом значения является ее тип, когда программа запущена (тип времени выполнения).
- Только значения, а не переменные, имеют типы: переменные просто имена, связанные со значениями.
- И абстрактные и конкретные типы могут быть параметризованы другими типами, символами (Symbols), и значениями любого типа, для которого
isbits()
возвращаетtrue
, а также их кортежами. Типовые параметры могут быть опущены, когда нет необходимости в их уточнении/ограничении.
Система типов в Julia создана быть мощьной, выразительной, но интуитивно понятной и ненавязчивой. Многие программисты, вообще, или почти не используют типы. Однако, некоторые виды программ становятся яснее, проще, быстрее и надежнее с объявленными типами.
Объявления типа ::
Для аннотации типов используется оператор ::
. Его использование сводится к двум случаям.
- Будучи добавленным к вычисленному выражению, он понимается как "является экземпляром типа". Эта форма может быть использована везде, где есть желание ввести утверждение-проверку (typeassert), позволяющее убедиться что тип выражения тот, который вы ожидаете. При невыполнении условия утверждения возникает ошибка. Если указанный тип является абстрактным, то выражение слева от него должно быть одним из его подтипов. Если указан конкретный тип, он выражение может быть только этого типа:
julia> (1+2)::AbstractFloat
ERROR: TypeError: typeassert: expected AbstractFloat, got Int64
...
julia> (1+2)::Int
3
- Будучи использовано а) с переменной, которой присваивается значение (
x::Int8 = 10
) или б) как часть объявленияlocal
(local x::Int8
) оператор::
устанавливает, что указанная переменная всегда будет иметь этот тип (подобно объявлению типа переменной в статически-типизированных языках). Каждое присваиваемое этой переменной значение, если оно другого типа, будет преобразовано в требуемый тип с помощью функцииconvert()
. (Объявляемые в REPL глобальные переменные не поддерживают эту возможность).
julia> function foo()
x::Int8 = 100
x
end
foo (generic function with 1 method)
julia> foo()
100
julia> typeof(ans)
Int8
в) Также определение типа может быть использовано при определении функции, указывая на тип возвращаемого значения. Возвращаемое функцией значение ведет себя так же как и переменная которой назначили тип: перед возвратом из функции она будет перобразовываться к указанному типу:
function sinc(x)::Float64
if x == 0
return 1
end
return sin(pi*x)/(pi*x)
end
Абстрактные типы
Абстрактные типы не могут быть созданы, и служат только в качестве узлов в графе иерархии типов. Объявление их имеет вид:
abstract «name»
abstract «name» <: «supertype»
Если супертип не задан - он прнимается равным Any
. Оператор <:
здесь читается как "является подтипом" (левое подтипом правого). Пример, того как в самом языке описывается иерархия абстрактных числовых типов:
abstract Number
abstract Real <: Number
abstract AbstractFloat <: Real
abstract Integer <: Real
abstract Signed <: Integer
abstract Unsigned <: Integer
Оператор <:
может использоваться в тексте программы как условный оператор проверки типов, который возвращает true, если левый опреанд является подтипом правого (или является тем же типом):
julia> Integer <: Number
true
julia> Integer <: AbstractFloat
false
julia> Int64<:Int64
true
Основная цель существования абстрактных типов - группировать конкретные типы, чтобы можно было задавать поведение по умолчанию для целой группы.
Рассмотрим пример, пусть мы объявили функцию:
function myplus(x,y)
x+y
end
Это объявление метода функции myplus
на двух аргументах типа Any
.
Когда мы вызовем эту функцию: myplus(2,5)
, диспетчер будет искать наиболее конкретную реализацию myplus
для двух аргументов типа Int
(Int
- это псевдоним, указывающий, например, на Int64
). За неимением более конкретного, будет скомпилирована реализация:
function myplus(x::Int,y::Int)
x+y
end
и, наконец, он вызывает эту конкретную реализвцию.
Если вы определите метод:
function myplus(x::Int,y::Int)
(x+y)*2
end
то, при вызове myplus(2,5)
диспетчер будет использовать вашу более конкретную реализацию:
myplus(2,5)
14
Хотя для другой комбинации типов будет вызвана более общая реализация:
myplus(2, 5.0)
7.0
Таким образом, абстрактные типы позволяют программистам писать общие функции, которые в дальнейшем могут быть использованы в качестве метода по умолчанию для многих комбинаций конкретных типов, а при необходимости, могут быть переопределены для конкретных типов (или подтипов абстрактных типов).
Битовые типы (Bits Types)
Битовый тип представляет собой конкретный тип, данные которого состоит из простых старых битов. Классическими примерами типов битов являются целые числа и значения с плавающей точкой. В отличие от большинства языков, Джулия позволяет объявлять собственные битовые типы, а не предоставляет только фиксированный набор встроенных типов битов. Стандартные битовые типы определенные в самом языке:
bitstype 16 Float16 <: AbstractFloat
bitstype 32 Float32 <: AbstractFloat
bitstype 64 Float64 <: AbstractFloat
bitstype 8 Bool <: Integer
bitstype 32 Char
bitstype 8 Int8 <: Signed
bitstype 8 UInt8 <: Unsigned
bitstype 16 Int16 <: Signed
bitstype 16 UInt16 <: Unsigned
bitstype 32 Int32 <: Signed
bitstype 32 UInt32 <: Unsigned
bitstype 64 Int64 <: Signed
bitstype 64 UInt64 <: Unsigned
bitstype 128 Int128 <: Signed
bitstype 128 UInt128 <: Unsigned
Синтаксис для объявлении bitstype
:
bitstype «bits» «name»
bitstype «bits» «name» <: «supertype»
Число битов, указывает на то, сколько памяти требует тип, а название дает новое имя типа. Битовые типы могут необязательно быть объявлены подтипом некоторого надтипа. Если супертип опущен, то по умолчанию тип имеет тип Any своим непосредственным надтипом. Декларация Bool
выше, означает, что логическое значение принимает восемь бит для хранения, и имеет Integer
в качестве своего непосредственного надтипа. В настоящее время только размеры, кратные 8 бит поддерживаются. Таким образом, логические значения, хотя они на самом деле нужно только один бит, не может быть объявлен любой меньше восьми бит.
Типы Bool, INT8 и Uint8 все имеют одинаковые представления: они Восьмибитовое куски памяти. Так как система типа Julia номинативная, они не являются взаимозаменяемыми, несмотря на одинаковую структуру.
Составные типы (Composite Types)
Составные типы называются записями или структурами в различных языках. Составной тип представляет собой набор именованных полей, экземпляр которого можно рассматривать как одно значение.
type Foo
bar
baz::Int
qux::Float64
end
Без аннотации типа (как bar
), поле может иметь значение любого типа (подразумевается аннотация типа Any
)
Новые объекты создаются слкдующим образом:
julia> foo = Foo("Hello, world.", 23, 1.5)
Foo("Hello, world.",23,1.5)
julia> typeof(foo)
Foo
Когда тип так применяется (как функция), он называется конструктором. Автоматически создаются два конструктора по умолчанию: один принимает все типы параметров и применяет к ним, при необходимости, функцию convert()
. Второй принимает только точно совпадающие типы аргументов. Это позволяет объявлять свои дополнительные конструкторы без опасений, что сломается конструктор по умолчанию.
Узнать список полей можно с помощью функции fieldnames
:
julia> fieldnames(foo)
3-element Array{Symbol,1}:
:bar
:baz
:qux
Доступ к полям объекта:
julia> foo.bar
"Hello, world."
julia> foo.baz
23
julia> foo.qux
1.5
Можно изменять значения полей:
julia> foo.qux = 2
2
julia> foo.bar = 1//2
1//2
Составной тип без полей является синглтоном: может быть создан только один его экземпляр:
type NoFields
end
julia> is(NoFields(), NoFields())
true
Неизменяемые составные типы (Immutable Composite Types)
Неизменяемый тип определяется так:
immutable Complex
real::Float64
imag::Float64
end
Такие типы ведут себя так же, как другие составные типы, за исключением того, их экземпляры не могут быть изменены. Неизменные типы имеют ряд преимуществ:
- Они более эффективны в некоторых случаях. Типы, такие как пример выше могут быть упакованы эффективно в массивы, а в некоторых случаях компилятор способен оптимизировать выделение памяти.
- Нельзя нарушить инварианты, предусмотренные конструкторами типа.
- О них легче рассуждать.
Неизменяемый объект может содержать изменяемые объекты, такие как массивы. Сами по себе эти объекты (их содержимое) останутся изменчивыми.
В операторах присваивания и вызовах функций неизменяемые объекты передаются путем копирования. (а изменяемые - копированием ссылки).
Тип типов
Т.к. в Julia сами типы являются, в свою очередь, объектами. То и у них должен быть свой тип. Этим типом является DataType
:
julia> typeof(Real)
DataType
julia> typeof(Int)
DataType
Объединение типов (Union)
Указать, что значение может быть одним из нескольких типов можно с помощью конструктора Union
:
julia> IntOrString = Union{Int,AbstractString}
Union{AbstractString,Int64}
julia> 1 :: IntOrString
1
julia> "Hello!" :: IntOrString
"Hello!"
julia> 1.0 :: IntOrString
ERROR: type: typeassert: expected Union{AbstractString,Int64}, got Float64
Параметрические типы (Parametric Types)
Типы могут принимать параметры, такие декларации типов фактически вводят целое семейство новых типов:
type Point{T}
x::T
y::T
end
Здесь тип Point
может принимать типовой параметр T
. Примерами конкретного типа будут: Point{Float64}
или Point{Int64}
или даже Point{AbstractString}
. Однако Point
сам по себе - корректный тип:
julia> Point
Point{T}
Конкретный тип является подтипом параметрического
julia> Point{Float64} <: Point
true
julia> Point{AbstractString} <: Point
true
но Float64
не является подтипом Point
:
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}
неверно.
Если нужно определить, что параметр может иметь не только конкретный тип, а и его подтипы, то нужно делать так:
function norm{T<:Real}(p::Point{T})
sqrt(p.x^2 + p.y^2)
end
Это читается так: для функции norm
определим, что некий некий тип, назовем его T
, является подтипом (или типом ) Real
, а параметр p
должен быть типом T
.
Только один конструктор по умолчанию генерируется для параметрических типов, так как переопределение его не представляется возможным. Этот конструктор принимает любые аргументы и преобразует их в типы полей.
Параметры типов выводятся автоматически при использовании конструктора:
julia> Point(1.0,2.0)
Point{Float64}(1.0,2.0)
julia> typeof(ans)
Point{Float64}
julia> Point(1,2)
Point{Int64}(1,2)
julia> typeof(ans)
Point{Int64}
Объявление Point указывает, что оба его поля должны быть одного типа. Поэтому, если сделаем так, то получим ошибку:
julia> Point(1,2.5)
ERROR: MethodError: no method matching Point{T}(::Int64, ::Float64)
Абстрактные параметрические типы (Parametric Abstract Types)
Аналогичным образом можно объявить типовые параметры у абстрактного типа:
abstract Pointy{T}
и точно так же, как с составными типами:
julia> Pointy{Int64} <: Pointy
true
julia> Pointy{1} <: Pointy
true
и они так же инвариантны:
julia> Pointy{Float64} <: Pointy{Real}
false
julia> Pointy{Real} <: Pointy{Float64}
false
Параметрический абстрактный тип может быть надтипом параметрического составного:
type Point{T} <: Pointy{T}
x::T
y::T
end
Если объект точки (Point) не должен содержать не числовые поля, то можно сделать так:
abstract Pointy{T<:Real}
при этом:
julia> Pointy{Float64}
Pointy{Float64}
julia> Pointy{Real}
Pointy{Real}
julia> Pointy{AbstractString}
ERROR: TypeError: Pointy: in T, expected T<:Real, got Type{AbstractString}
...
julia> Pointy{1}
ERROR: TypeError: Pointy: in T, expected T<:Real, got Int64
...
потом сделаем так:
type Point{T<:Real} <: Pointy{T}
x::T
y::T
end
Кортежные типы (Tuple Types)
Кортежные типы - это абстракция аргументов для функции без самой функции. По поведению они отличаются от любых типов, описанных выше.
- Они могут иметь любое количество параметров.
- Они ковариантны, то есть:
Tuple{Int}
является подтипом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 (Vararg Tuple Types)
Последним аргументом кортежного типа может быть особый тип Vararg. Он означает любое количество завершающих параметров.
julia> isa(("1",), Tuple{AbstractString,Vararg{Int}})
true
julia> isa(("1",1), Tuple{AbstractString,Vararg{Int}})
true
julia> isa(("1",1,2), Tuple{AbstractString,Vararg{Int}})
true
julia> isa(("1",1,2,3.0), Tuple{AbstractString,Vararg{Int}})
false
Обратите внимание на то, что Vararg {T} соответствует нулю или более элементов типа T. NTuple {N, T} является удобным псевдонимом для кортежей {Vararg {T, N}}, т.е. типа кортеж, содержащий ровно N элементов типа T.
Типы-одиночки ( Singleton Types )
В документации объяснено плохо, что это, и зачем они нужны. Поэтому, здесь отступлю от нее, оставив оригинальным только определение. Честно, говоря, я даже определение перевел с трудом.
Определение .
Для любого типа T
тип-одиночка Type{T}
- это абстрактный тип, чей единственный экземпляр является объектом T
.
Понять это можно так: в Julia не только любое значение является объектом (определенного типа), но и сами типы объектов являются объектами... но какого типа? Тип любого типа - это DataType
. Но, он не информативен. Нужен способ описать тип, чтобы можно было использовать эту информацию позже. Нужное, как раз описывается типом Type{T}
.
Теперь, зачем это нужно на примере работы функций. Как мы знаем, функция может принимать аргументы. Кроме самого значения, она имеет доступ и к типу переменной, например, с помощью typeof()
:
f(x) = "Значение $x типа $(typeof(x))
Некоторые функции не используют полученное значение аргумента, так как им важен лишь его тип значения:
f(::AbstractString) = "Со строками не работаем"|>error
А некоторые добрые функции, зная что будут работать только с типом значения, не хотят заставлять нас создавать экземпляр значения. Это возможно, потому что сами типы являются объектами, которые можно передавать аргументами в функции. Прежде чем производить дорогостоящую аллокацию строки, делать на удачу вызов функции с этой строкой, а потом получать брошенное исключение, что тоже является дорогой операцией, мы могли бы прямо спросить у некоторой функции, будет ли она работать со строкой или ей удобнее работать с числом:
julia> do_you_work_with( AbstractString )
false
julia> do_you_work_with( Int )
true
Как же как же может выглядеть определение функции, принимающей вместо значения - только тип? А вот как:
do_you_work_with{T<:AbstractString}( x::Type{T}) = false
do_you_work_with{T<:Number}( x::Type{T}) = true
Возвращаюсь к документации.
Функция isa(x, type) -> Bool
- возвращает true
, когда x
является значением типа type
.
Пример:
julia> isa(1,Int)
true
Можно сказать: выражение isa( A, Type{B})
тогда и только, когда A
и B
являются одним и тем же объектом и этот объект - тип.
julia> isa(Float64, Type{Float64})
true
julia> isa(Real, Type{Float64})
false
julia> isa(Real, Type{Real})
true
julia> isa(Float64, Type{Real})
false
Будучи не параметризованным тип Type
является типом любого типа:
julia> isa(Type{Float64},Type)
true
julia> isa(Float64,Type)
true
julia> isa(Real,Type)
true
Операции на типах
Поскольку типы в Julia сами по себе являются объектами, обычные функции могут работать с ними. Есть уже готовые функции, например, <:
проверяющая , что левый операнд является подтипом правого.typeof()
возвращает тип своего аргумента. supertype()
возвращает супертип своего аргумента для декларированных типов (объектов типа DataType
). Для не типовых объектов она вернет ошибку:
julia> supertype(Union{Float64,Int64})
ERROR: `supertype` has no method matching supertype(::Type{Union{Float64,Int64}})
Типы-значения ( “Value types” )
Julia не позволяет определять диспетчеризацию по значениям. Но она позволяет это делать по параметрическим типам , и вы можете использовать "простые битовые" значения (Types, Symbols, Integers, floating-point numbers, tuples, etc.) как типовые параметры. Типичным примером является параметр размерности в Array{T,N}
, где Т
представляет собой тип (например, Float64
), но N
является только Int
. Вы можете создавать свои собственные типы, которые принимают значения в качестве параметров, и использовать их для управления диспетчеризацией пользовательских типов. В качестве иллюстрации этой идеи, давайте введем параметрический тип, Val{T}
, который служит способом использовать эту технику для тех случаев, когда вам не нужна более сложная иерархия. Val
объявлен так:
immutable Val{T}
end
Больше там ничего нет. Некоторые функции в стандартной библиотек принимают тип Val
в качестве аргумента, и вы можете использовать его, чтобы написать свои собственные функции. Например:
firstlast(::Type{Val{true}}) = "First"
firstlast(::Type{Val{false}}) = "Last"
julia> firstlast(Val{true})
"First"
julia> firstlast(Val{false})
"Last"
Обратите внимание, что используется тип, в общем виде: foo(Val{:bar})
, вместо создания экземпляра: foo(Val{:bar}())
Но, нужно быть внимательным, применяя Val
. Можно нечаянно снизить производительность своего кода, неправильным его использованием. Прочитайте советы по повышению производительности прежде чем его применить (http://docs.julialang.org/en/stable/manual/performance-tips/\#man-performance-val ).
Nullable
Обеспечивает минимальный интерфейс, позволяющий убедиться в том, что использование содержащегося (или не содержащегося ) в нем значения будет безопасным.
Чтобы создать отсутствующее значение, нужно сделать так:
julia> x1 = Nullable{Int64}()
Nullable{Int64}()
julia> x2 = Nullable{Float64}()
Nullable{Float64}()
julia> x3 = Nullable{Vector{Int64}}()
Nullable{Array{Int64,1}}()
Создать НЕ отсутствующее значение, можно так:
julia> x1 = Nullable(1)
Nullable{Int64}(1)
julia> x2 = Nullable(1.0)
Nullable{Float64}(1.0)
julia> x3 = Nullable([1, 2, 3])
Nullable{Array{Int64,1}}([1,2,3])
Проверить наличие/отсутствие значение в Nullable можно так:
julia> isnull(Nullable{Float64}())
true
julia> isnull(Nullable(0.0))
false
Доступ к значению, (ради него все и затевалось):
julia> get(Nullable{Float64}())
ERROR: NullException()
in get(::Nullable{Float64}) at ./nullable.jl:62
...
julia> get(Nullable(1.0))
1.0
Если значения нет - бросается исключение. Если вы можете предоставить разумное значение по умолчанию для отсутствующего значения, то вы можете сделать так:
julia> get(Nullable{Float64}(), 0.0)
0.0
julia> get(Nullable(1.0), 0.0)
1.0
Значение по умолчанию будет автоматически преобразовано в ожидаемый тип.