Типы данных в Julia
Типы в Julia¶
Примитивный тип: тип, определяемый с помощью ключевого слова
primitive type
. Объекты примитивного типа имеют заданный фиксированный размер памяти, указанный в определении типа. 📝Int64
,Bool
,Char
Составной тип: тип, определяемый с помощью ключевого слова
struct
. Составные типы состоят из нуля или более полей, ссылающихся на другие объекты (примитивного или составного типа).📝Complex
,Rational
(поляre, im
иnum, den
, соответственно),Tuple
Конкретный тип: примитивный или составной тип
Абстрактный тип: тип, определяемый с помощью ключевого слова
abstract type
. Абстрактные типы не имеют полей, и объекты не могут быть созданы (инстанцированы) на их основе. Кроме того, они не могут быть объявлены дочерними по отношению к конкретному типу. Также к абстрактным типам относятся не конкретные типы.📝Number
,AbstractFloat
Изменяемый тип: составной тип, определяемый с помощью ключевого слова
mutable struct
. Изменяемые типы могут связывать свои поля с другими объектами, отличными от тех, с которыми они были связаны во время инициализации.📝String
,Dict
Неизменяемый тип: все типы, кроме тех, которые определяются с помощью
mutable struct
.Параметрический тип: семейство (изменяемых или неизменяемых) составных или абстрактных типов с одинаковыми именами полей и названием типа без учёта типов параметров. Определённый тип затем однозначно идентифицируется по имени параметрического типа и типу (типам) параметра (параметров). 📝
Rational{Int8}(1,2)
, см. нижеAbstractArray{T,N}
,Array{T,N}
Исходные типы: тип, определение которого содержится в Julia Base или в стандартной библиотеке Julia
Битовый тип: примитивный или неизменяемый составной тип, все поля которого являются битовыми типами
Синглтон: объект, созданный на основе составного типа, состоящего из нуля полей. 📝
nothing
,missing
Контейнер: составной тип (не обязательно изменяемый), предназначенный для ссылки на переменное количество объектов и предоставляющий методы для доступа, перебора и, в конечном итоге, изменения ссылок на другие объекты.
Примитивный тип¶
Несмотря на то, что в документации не рекомендуется использовать конструкция primitive type
, я предлагаю начать знакомство с типами именно с примитивных.
Это сделано потому, что здесь мы опустимся на самый низкий уровень, где будет видно то, как в конечном итоге будут представлены данные в памяти.
В качестве примера введём наш "защищённый от помех из космоса" Bool, который заполняет все возможные доступные битовые ячейки либо 0
либо 1
.
При создании примитивного типа необходимо явно указывать сколько бит необходимо для хранения этого типа. (В нашем случае 8
)
primitive type FilledBool 8 end
function FilledBool(x::Int)
if iszero(x)
reinterpret(FilledBool,0b00000000)
elseif x == 1
reinterpret(FilledBool,0b11111111)
else
error("В качестве параметров допустимы только 0 и 1")
end
end
Base.show(io :: IO, x :: FilledBool) = print(io, bitstring(x))
@show tr = FilledBool(1)
@show fls = FilledBool(0)
println("Regular Bool true: ", bitstring(true))
Проверим, является ли наш тип битовым:
isbitstype(FilledBool)
В документации говорится, что вместо того, чтобы создавать собственные примитивные типы - лучше делать обёртку над ними в в виде составного типа. Давайте же познакомимся с ним поближе!
Составной тип¶
Неизменяемый составной тип¶
Важно понимать, что составной тип может состоять как из нескольких полей, так и из одного или нуля полей. В отличие от многих других языков программирования, где с объектом связаны поля и методы, к составному типу в Julia привязаны только поля и его конструктор. Про то, как соотносятся ООП и Julia интересно рассказывают здесь
Но мы пока сосредоточимся на типах.
Пускай у нас есть тип "Гора". Мы указываем 2 характеристики объектов этого типа:
- год покорения (год может быть положительным или отрицательным)
- высота горы (предположим, что все горы выше уровня моря)
В неизменяемых типах после их создания нельзя поменять поля.
struct Mountain
first_ascent_year::Int16
height::UInt16
end
Everest = Mountain(1953,8848)
Int(Everest.height)
try
Everest.height = 9000 # нельзя менять значения полей Mountain
catch e
e
end
Чтобы подробнее посмотреть на то, как устроена структура, можно воспользоваться:
dump(Everest)
Каждый тип элемента неизменяемой структуры Mountain является битовым, поэтому тип Mountain является битовым
@show sizeof(Mountain) # 2 поля по 2 байта = 4
isbitstype(Mountain)
Рассмотрим случай, когда полями неизменяемой структуры является не битовый тип.
Строка хранится не как массив элементов Char
'ов, а как указатель на массив Char
'ов.
Поэтому размер структуры - 8 байт (размер указателя), а размер строки - 6 байт.
(Хотя sizeof(Char)=4
, в случае ASCII они будут занимать 1 байт)
struct City
name::String
end
Moscow = City("Moscow")
Moscow.name
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
Если требуется использовать статические строки, то
import Pkg.add; Pkg.add("StaticStrings")
using StaticStrings
struct StaticCity
name::StaticString{10}
end
Moscow = StaticCity(static"Moscow"10) # дополняется \0 до 10
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
Несмотря на то, что мы не можем изменить строку, этот тип не является битовым.
Т.е. важно понимать отличие между неизменяемым и битовым типами.
Необычное поведение функции ismutable("123") объясняется здесь
@show isbitstype(City)
@show isbitstype(StaticCity);
Хочется отдельно отметить, что неизменяемый тип может иметь неизменяемые поля изменяемого типа.
В качестве аналогии: Пускай у нас есть верёвочка, к которой привязан воздушный шарик, который мы можем изменять: растягивать, надувать, наполнять водой. Но мы не можем оторвать верёвочку и прикрепить к ней зелёный мяч.
struct Student
name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Alex = Student("Alex", 1, [5,5,5])
@show sizeof(Alex) # 8 + 1 + 8 = 17 => 24 округление до x % 8 == 0
pointer(Alex.grades)
push!(Alex.grades,4)
Alex.grades
@show pointer(Alex.grades)
Как можем заметить - мы меняем элементы вектора, но не указатель на его первый элемент.
# разыменование указателя на вектор (1й элемент вектора)
unsafe_load(pointer(Alex.grades))
А если же мы захотим поменять не элементы вектора, а указатель на вектор, то произойдёт ошибка.
try
Alex.grades = [1, 2, 3] # здесь же мы хотим
catch e
e
end
Изменяемый тип¶
В случае же изменяемого типа мы можем менять поля.
mutable struct MutableStudent
const name::String
grade::UInt8 # класс
grades::Vector{Int} # оценки
end
Peter = MutableStudent("Peter", 1, [5,5,5])
Peter.grade = 2
Но есть возможность делать некоторые поля изменяемой структуры неизменяемыми (константными). В этом случае, несмотря на то, что структура - изменяемая, это поле не получится поменять.
try
Peter.name = "Alex"
catch e
e
end
Можно заметить, как теперь мы можем менять вектор на другой:
@show pointer(Peter.grades)
@show Peter.grades = [2,2,2]
@show pointer(Peter.grades)
Отличие неизменяемой struct от mutable struct с константными полями.¶
Несмотря на то, что поля неизменяемой структуры и константные поля изменяемой структуры нельзя менять, есть существенная разница между объектами таких типов с одинаковыми полями.
В случае с неизменяемым типом - объекты с одинаковыми полями это буквально один и тот же объект, так как все объекты с одинаковыми полями будут располагаться по одному адресу.
В случае с mutable struct
каждый из объектов с одинаковыми константными полями будут располагаться по своему уникальному адресу.
struct Immutable
a::Int32
b::Int32
end
mutable struct ConstMutable
const a::Int32
const b::Int32
end
im_obj_1 = Immutable(1,2)
im_obj_2 = Immutable(1,2)
const_mut_obj_1 = ConstMutable(1,2)
const_mut_obj_2 = ConstMutable(1,2)
# === означает равенство и значений и адресов в памяти
@show im_obj_1 === im_obj_2
@show const_mut_obj_1 === const_mut_obj_2
Неизменяемые структуры могут быть не такие удобные в плане интерфейса их использования. Но их преимуществом является размещение "на стеке". В то время как изменяемые структуры обычно хранятся "в куче".
println(@allocations (a = Immutable(3,4); b = Immutable(3,4)))
println(@allocations (a = ConstMutable(3,4); b = ConstMutable(3,4)))
Однако к этому утверждению не нужно относиться буквально.
Так, например, компилятор может провести оптимизации и не выделять память для изменяемых структур внутри функции, которая будет возвращать число, а не изменяемую структуру:
function foo(x,y)
obj1 = Immutable(x,y)
obj2 = Immutable(y,x)
c = obj1.a + obj2.b
end
function bar(x,y)
obj1 = ConstMutable(x,y)
obj2 = ConstMutable(y,x)
c = obj1.a + obj2.b
end
println(@allocations foo(1,2))
println(@allocations bar(1,2))
Абстрактный тип¶
Для чего нужны абстрактные типы? Абстрактные типы нужны для того чтобы:
- группировать конкретные типы
- задавать интерфейсы для функций
- управлять областью создания других классов при помощи параметризации (см. ниже)
Группирование конкретных типов¶
Благодаря абстрактным типам можно организовывать иерархии типов.
Рассмотрим классический и наиболее понятный тип - Number
.
Используя A <: B
Мы можем указывать или проверять то, что тип A
является подтипом B
Int8 <: Integer || Int16 <: Integer
subtypes(Signed)
Также можно работать и в обратную сторону:
B :> A
показывает, что B
является надтипом A
А функция supertypes возвращает упорядоченный слева-направо по возрастанию кортеж надтипов
supertypes(Int8)
Но более визуально приятным расширением является пакет AbstractTrees, позволяющий нам получить многим знакомую картинку.
using AbstractTrees
AbstractTrees.children(t::Type) = subtypes(t)
print_tree(Number) # здесь можно видеть типы и от Engee
Однако я рекомендую запустить print_tree(Any)
и погрузиться в удивительный мир типов Julia))))
Абстрактные типы и множественная диспетчеризация¶
Заканчивая рассуждения про наши числа, хочется отметить, что логично, что любые два числа можно сложить.
Поэтому в promotion.jl есть следующая строчка:
+(x::Number, y::Number) = +(promote(x,y)...)
(с помощью methods(+)
можно посмотреть что с чем и по каким правилам складывается)
Хотя, например наименьшее общее кратное должно быть определено только для целочисленных или рациональных. Его мы обсудим в конце.
И для тех, кто устал от чисел, предлагаю вернуться к нашим баранам
abstract type Pet end
struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end
function encounter(a::Pet, b::Pet)
verb = meets(a, b)
println("$(a.name) встречает $(b.name) и $verb.")
end
meets(a::Dog, b::Dog) = "нюхает"
meets(a::Dog, b::Cat) = "гонится"
meets(a::Cat, b::Dog) = "шипит"
meets(a::Cat, b::Cat) = "мурлычит"
fido = Dog("Рекс")
rex = Dog("Мухтар")
whiskers = Cat("Матроскин")
spots = Cat("Бегемот")
encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)
Удобство состоит в том, что мы можем не уточнять под каждое животное, как оно здоровается с другим, а сделать общий интерфейс "приветствия" для животных.
meets(a::Pet, b::Pet) = "здоровается"
struct Cow <: Pet; name::String end
encounter(rex,Cow("Бурёнка"))
Не во всех языках это работает так удобно. Подробнее об этом можно посмотреть в видео, из которого был взят этот код.
DataType¶
Но перед тем, как перейти к последнему разделу
Внесём смуту!
И пока что просто посмотрим на странного зверя - DataType
123 isa Integer
Vector isa DataType || Dict isa DataType
Function isa DataType
Не волнуйтесь, объяснения будут даны.
Параметризированные типы¶
Параметризированными могут быть как составные, так и абстрактные и даже примитивные типы.
Начнём с более очевидной разновидности:
Параметризированные составные типы¶
могут быть полезны тогда, когда важно сохранение логики структуры, но тип полей объектов может меняться.
Так, например, можно догадаться, что из себя представляет комплексное число:
struct Complex{T<:Real} <: Number
re::T
im::T
end
ci8 = Int8(1)+Int8(2)im
@show typeof(ci8)
sizeof(ci8)
cf64 = 1.5+2im
@show typeof(cf64)
sizeof(cf64)
Как видим,в зависимости от того, какие параметры мы передавали, получаются объекты разных типов. Они занимают разное количество памяти и могут работать по-разному.
Параметризованные абстрактные типы¶
Примером параметризованного абстрактного типа может служить AbstractDict
abstract type AbstractDict{K,V} end
Словарь же в свою очередь является:
mutable struct Dict{K,V} <: AbstractDict{K,V}
slots::Memory{UInt8}
keys::Memory{K}
vals::Memory{V}
...
...
end
Это нужно для того, чтобы реализовывать эффективные интерфейсы.
Например набор переменных окружения ENV
не является Dict, зато является AbstractDict.
При этом важно, чтобы ENV был параметрический AbstractDict{String,String}
.
Поэтому параметрические абстрактные типы бывают очень удобными.
ENV isa Dict
ENV isa AbstractDict
НАКОНЕЦ, МАССИВЫ!¶
И только теперь, дойдя до параметрический абстрактных типов, мы можем понять, что такое массивы.
Несмотря на то, что реализация массивов написана на C, мы можем посмотреть, что из себя представляют
[1,2,3]
[1 2 3;
4 5 6;
7 8 9;]
и rand(3,3,3)
Всё дело в определении этого типа:
abstract type AbstractArray{T,N} end
N здесь можно было бы обозначить как
abstract type AbstractArray{T,N<:Unsigned} end
Array <: DenseArray <: AbstractArray
Array{Int8,1}(undef,4)
Array{Int8,2}(undef,2,3)
Array{Int8,3}(undef,3,3,3)
И вот ответ на то, почему range в Julia поддерживает интерфейс массива:
1:0.5:1000 isa StepRangeLen <: AbstractArray
Т.е параметрический тип схож на шаблоны в C++.
Но важно понимать особенности типов в Julia¶
Параметрические типы в Julia являются инвариатными
Vector{Int} <: Vector{Real}
Vector{Int} <: Vector{<:Real}
Vector{Complex} <: Vector{<:Real}
Vector{Complex} <: Vector
Вот тут нам может помочь DataType. DataType позволяет понять, является ли тип "объявленным".
Все конкретные типы являются DataType.
- Большинство непараметрических типов являются DataType
abstract type Number end;
- Если мы указали параметры, то это тоже DataType.
Чтобы понять, что такое DataType - проще отталкиваться от того, что не является DataType.
Union{Int32,Char} isa DataType
Vector{<:Real} isa DataType # тоже своего рода "объединение всех векторов, чей тип является подтипом Real"
И как же это всё применяется ?¶
Множественная диспетчеризация обладает следующим приоритетом:
- Конкретный тип
- Абстрактный тип
- Параметризованные тип
Закончим мы тем, что посмотрим на то, как устроена функция набольшего общего кратного, где есть "перегруженные" функции:
#1
function lcm(a::T, b::T) where T<:Integer
#2
function lcm(x::Rational, y::Rational)
#3
lcm(a::Real, b::Real) = lcm(promote(a,b)...)
#4
lcm(a::T, b::T) where T<:Real = throw(MethodError(lcm, (a,b)))
Для случая целочисленных будет вызвана функция 1
Если же мы передадим рациональные параметры, то вызовется функция 2
Если мы передадим lcm(2, 2//3)
,то сначала вызовется функция 3 и произойдёт продвижение типов. После чего вызовется функция 2.
Но если мы вызовем lcm(2, 1.5) , то после продвижения типов мы попадём в 4 - "шаблонную" версию, где уже будет вызвана ошибка.
Это похоже на правило
promote(2, 2//3)
До скорых встреч!