Типы данных в Julia

Автор
avatar-igarajaigaraja
Notebook

Введение

Скрипт про типы данные в дополнение к статье на habr.com.

Типы в 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)

In [ ]:
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))
tr = FilledBool(1) = 11111111
fls = FilledBool(0) = 00000000
Regular Bool true: 00000001

Проверим, является ли наш тип битовым:

In [ ]:
isbitstype(FilledBool)
Out[0]:
true

В документации говорится, что вместо того, чтобы создавать собственные примитивные типы - лучше делать обёртку над ними в в виде составного типа. Давайте же познакомимся с ним поближе!

Составной тип

Неизменяемый составной тип

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

Но мы пока сосредоточимся на типах.

Пускай у нас есть тип "Гора". Мы указываем 2 характеристики объектов этого типа:

  • год покорения (год может быть положительным или отрицательным)
  • высота горы (предположим, что все горы выше уровня моря)

В неизменяемых типах после их создания нельзя поменять поля.

In [ ]:
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
Out[0]:
ErrorException("setfield!: immutable struct of type Mountain cannot be changed")

Чтобы подробнее посмотреть на то, как устроена структура, можно воспользоваться:

In [ ]:
dump(Everest)
Mountain
  first_ascent_year: Int16 1953
  height: UInt16 0x2290

Каждый тип элемента неизменяемой структуры Mountain является битовым, поэтому тип Mountain является битовым

In [ ]:
@show sizeof(Mountain) # 2 поля по 2 байта = 4
isbitstype(Mountain)
sizeof(Mountain) = 4
Out[0]:
true

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

Строка хранится не как массив элементов Char'ов, а как указатель на массив Char'ов. Поэтому размер структуры - 8 байт (размер указателя), а размер строки - 6 байт. (Хотя sizeof(Char)=4, в случае ASCII они будут занимать 1 байт)

In [ ]:
struct City
    name::String
end

Moscow = City("Moscow")

Moscow.name

@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
sizeof(Moscow) = 8
sizeof(Moscow.name) = 6
Base.summarysize(Moscow) = 22

Если требуется использовать статические строки, то

In [ ]:
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);
sizeof(Moscow) = 10
sizeof(Moscow.name) = 10
Base.summarysize(Moscow) = 10

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

Т.е. важно понимать отличие между неизменяемым и битовым типами.

Необычное поведение функции ismutable("123") объясняется здесь

In [ ]:
@show isbitstype(City)
@show isbitstype(StaticCity);
isbitstype(City) = false
isbitstype(StaticCity) = true

Хочется отдельно отметить, что неизменяемый тип может иметь неизменяемые поля изменяемого типа.

В качестве аналогии: Пускай у нас есть верёвочка, к которой привязан воздушный шарик, который мы можем изменять: растягивать, надувать, наполнять водой. Но мы не можем оторвать верёвочку и прикрепить к ней зелёный мяч.

In [ ]:
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
sizeof(Alex) = 24
Out[0]:
24
In [ ]:
pointer(Alex.grades)
Out[0]:
Ptr{Int64} @0x00007f18d58967a0
In [ ]:
push!(Alex.grades,4)
Alex.grades
Out[0]:
4-element Vector{Int64}:
 5
 5
 5
 4
In [ ]:
@show pointer(Alex.grades)
pointer(Alex.grades) = Ptr{Int64} @0x00007f18d370a0e0
Out[0]:
Ptr{Int64} @0x00007f18d370a0e0

Как можем заметить - мы меняем элементы вектора, но не указатель на его первый элемент.

In [ ]:
# разыменование указателя на вектор (1й элемент вектора)
unsafe_load(pointer(Alex.grades)) 
Out[0]:
5

А если же мы захотим поменять не элементы вектора, а указатель на вектор, то произойдёт ошибка.

In [ ]:
try
Alex.grades = [1, 2, 3] # здесь же мы хотим 
catch e
    e
end
Out[0]:
ErrorException("setfield!: immutable struct of type Student cannot be changed")

Изменяемый тип

В случае же изменяемого типа мы можем менять поля.

In [ ]:
mutable struct MutableStudent
    const name::String
    grade::UInt8        # класс
    grades::Vector{Int} # оценки
end
Peter = MutableStudent("Peter", 1, [5,5,5])
Peter.grade = 2
Out[0]:
2

Но есть возможность делать некоторые поля изменяемой структуры неизменяемыми (константными). В этом случае, несмотря на то, что структура - изменяемая, это поле не получится поменять.

In [ ]:
try
    Peter.name = "Alex"
catch e
    e
end
Out[0]:
ErrorException("setfield!: const field .name of type MutableStudent cannot be changed")

Можно заметить, как теперь мы можем менять вектор на другой:

In [ ]:
@show pointer(Peter.grades)
@show Peter.grades = [2,2,2]
@show pointer(Peter.grades)
pointer(Peter.grades) = Ptr{Int64} @0x00007f18572077e0
Peter.grades = [2, 2, 2] = [2, 2, 2]
pointer(Peter.grades) = Ptr{Int64} @0x00007f185714e430
Out[0]:
Ptr{Int64} @0x00007f185714e430

Отличие неизменяемой struct от mutable struct с константными полями.

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

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

В случае с mutable struct каждый из объектов с одинаковыми константными полями будут располагаться по своему уникальному адресу.

In [ ]:
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
im_obj_1 === im_obj_2 = true
const_mut_obj_1 === const_mut_obj_2 = false
Out[0]:
false

Неизменяемые структуры могут быть не такие удобные в плане интерфейса их использования. Но их преимуществом является размещение "на стеке". В то время как изменяемые структуры обычно хранятся "в куче".

In [ ]:
println(@allocations (a = Immutable(3,4); b = Immutable(3,4)))
println(@allocations (a = ConstMutable(3,4); b = ConstMutable(3,4)))
0
2

Однако к этому утверждению не нужно относиться буквально.

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

In [ ]:
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))
0
0

Абстрактный тип

Для чего нужны абстрактные типы? Абстрактные типы нужны для того чтобы:

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

Группирование конкретных типов

Благодаря абстрактным типам можно организовывать иерархии типов.

Рассмотрим классический и наиболее понятный тип - Number.

Используя A <: B Мы можем указывать или проверять то, что тип A является подтипом B

In [ ]:
Int8 <: Integer || Int16 <: Integer
Out[0]:
true
In [ ]:
subtypes(Signed)
WARNING: both ImageMetadata and ImageAxes export "data"; uses of it in module Images must be qualified
Out[0]:
6-element Vector{Any}:
 BigInt
 Int128
 Int16
 Int32
 Int64
 Int8

Также можно работать и в обратную сторону: B :> A показывает, что B является надтипом A

А функция supertypes возвращает упорядоченный слева-направо по возрастанию кортеж надтипов

In [ ]:
supertypes(Int8)
Out[0]:
(Int8, Signed, Integer, Real, Number, Any)

Но более визуально приятным расширением является пакет AbstractTrees, позволяющий нам получить многим знакомую картинку.

In [ ]:
using AbstractTrees
AbstractTrees.children(t::Type) = subtypes(t)
print_tree(Number) # здесь можно видеть типы и от Engee
Number
├─ MultiplicativeInverse
│  ├─ SignedMultiplicativeInverse
│  └─ UnsignedMultiplicativeInverse
├─ Complex
├─ Measurement
├─ Quaternion
├─ Real
│  ├─ AbstractFloat
│  │  ├─ BigFloat
│  │  ├─ DecimalFloatingPoint
│  │  │  ├─ Dec128
│  │  │  ├─ Dec32
│  │  │  └─ Dec64
│  │  ├─ Float16
│  │  ├─ Float32
│  │  └─ Float64
│  ├─ AbstractIrrational
│  │  ├─ Irrational
│  │  └─ IrrationalConstant
│  │     ├─ Fourinvπ
│  │     ├─ Fourπ
│  │     ├─ Halfπ
│  │     ├─ Inv2π
│  │     ├─ Inv4π
│  │     ├─ Invsqrt2
│  │     ├─ Invsqrt2π
│  │     ├─ Invsqrtπ
│  │     ├─ Invπ
│  │     ├─ Log2π
│  │     ├─ Log4π
│  │     ├─ Loghalf
│  │     ├─ Logten
│  │     ├─ Logtwo
│  │     ├─ Logπ
│  │     ├─ Quartπ
│  │     ├─ Sqrt2
│  │     ├─ Sqrt2π
│  │     ├─ Sqrt3
│  │     ├─ Sqrt4π
│  │     ├─ Sqrthalfπ
│  │     ├─ Sqrtπ
│  │     ├─ Twoinvπ
│  │     └─ Twoπ
│  ├─ FixedPoint
│  │  ├─ Fixed
│  │  └─ Normed
│  ├─ Dual
│  ├─ Percentile
│  ├─ Integer
│  │  ├─ Bool
│  │  ├─ UpperBoundedInteger
│  │  ├─ ChainedVectorIndex
│  │  ├─ Signed
│  │  │  ├─ BigInt
│  │  │  ├─ Int128
│  │  │  ├─ Int16
│  │  │  ├─ Int32
│  │  │  ├─ Int64
│  │  │  └─ Int8
│  │  └─ Unsigned
│  │     ├─ VarUInt
│  │     ├─ UInt128
│  │     ├─ UInt16
│  │     ├─ UInt32
│  │     ├─ UInt64
│  │     └─ UInt8
│  ├─ Rational
│  ├─ SimpleRatio
│  ├─ StaticFloat64
│  ├─ PValue
│  ├─ TestStat
│  ├─ LiteralReal
│  ├─ SafeReal
│  ├─ Num
│  ├─ Struct
│  └─ AbstractSIMD
│     ├─ AbstractSIMDVector{W, T} where {W, T<:(Union{Bool, Float16, Float32, Float64, Int16, Int32, Int64, Int8, UInt16, UInt32, UInt64, UInt8, Bit, var"#s3"} where var"#s3"<:StaticInt)}
│     │  ├─ AbstractMask
│     │  │  ├─ EVLMask{W, U} where {W, U<:Union{UInt128, UInt16, UInt32, UInt64, UInt8}}
│     │  │  └─ Mask{W, U} where {W, U<:Union{UInt128, UInt16, UInt32, UInt64, UInt8}}
│     │  ├─ MM
│     │  └─ Vec{W, T} where {W, T<:(Union{Bool, Float16, Float32, Float64, Int16, Int32, Int64, Int8, UInt16, UInt32, UInt64, UInt8, Bit, var"#s3"} where var"#s3"<:StaticInt)}
│     └─ VecUnroll{N, W, T, V} where {N, W, T<:(Union{Bool, Float16, Float32, Float64, Int16, Int32, Int64, Int8, UInt16, UInt32, UInt64, UInt8, Bit, var"#s3"} where var"#s3"<:StaticInt), V<:Union{Bool, Float16, Float32, Float64, Int16, Int32, Int64, Int8, UInt16, UInt32, UInt64, UInt8, Bit, AbstractSIMD{W, T}}}
├─ StaticInteger
│  ├─ StaticBool
│  │  ├─ False
│  │  └─ True
│  └─ StaticInt
├─ AbstractQuantity
│  └─ Quantity
├─ LogScaled
│  ├─ Gain{L} where L<:LogInfo
│  └─ Level{L} where L<:LogInfo
├─ Double
└─ LazyMulAdd

Однако я рекомендую запустить print_tree(Any) и погрузиться в удивительный мир типов Julia))))

Абстрактные типы и множественная диспетчеризация

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

Поэтому в promotion.jl есть следующая строчка:

+(x::Number, y::Number) = +(promote(x,y)...)

(с помощью methods(+) можно посмотреть что с чем и по каким правилам складывается)

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

И для тех, кто устал от чисел, предлагаю вернуться к нашим баранам

In [ ]:
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) 
Рекс встречает Мухтар и нюхает.
Рекс встречает Матроскин и гонится.
Матроскин встречает Мухтар и шипит.
Матроскин встречает Бегемот и мурлычит.

Удобство состоит в том, что мы можем не уточнять под каждое животное, как оно здоровается с другим, а сделать общий интерфейс "приветствия" для животных.

In [ ]:
meets(a::Pet, b::Pet) = "здоровается"

struct Cow <: Pet; name::String end

encounter(rex,Cow("Бурёнка"))
Мухтар встречает Бурёнка и здоровается.

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

DataType

Но перед тем, как перейти к последнему разделу

Внесём смуту!

И пока что просто посмотрим на странного зверя - DataType

In [ ]:
123 isa Integer
Out[0]:
true
In [ ]:
Vector isa DataType || Dict isa DataType
Out[0]:
false
In [ ]:
Function isa DataType
Out[0]:
true

Не волнуйтесь, объяснения будут даны.

Параметризированные типы

Параметризированными могут быть как составные, так и абстрактные и даже примитивные типы.

Начнём с более очевидной разновидности:

Параметризированные составные типы

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

Так, например, можно догадаться, что из себя представляет комплексное число:

struct Complex{T<:Real} <: Number
    re::T
    im::T
end
In [ ]:
ci8 = Int8(1)+Int8(2)im
@show typeof(ci8)
sizeof(ci8)
typeof(ci8) = Complex{Int8}
Out[0]:
2
In [ ]:
cf64 = 1.5+2im
@show typeof(cf64)
sizeof(cf64)
typeof(cf64) = ComplexF64
Out[0]:
16

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

Параметризованные абстрактные типы

Примером параметризованного абстрактного типа может служить 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}. Поэтому параметрические абстрактные типы бывают очень удобными.

In [ ]:
ENV isa Dict
Out[0]:
false
In [ ]:
ENV isa AbstractDict
Out[0]:
true

НАКОНЕЦ, МАССИВЫ!

И только теперь, дойдя до параметрический абстрактных типов, мы можем понять, что такое массивы.

Несмотря на то, что реализация массивов написана на 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
In [ ]:
Array <: DenseArray <: AbstractArray
Out[0]:
true
In [ ]:
Array{Int8,1}(undef,4)
Out[0]:
4-element Vector{Int8}:
 -32
  40
  89
  59
In [ ]:
Array{Int8,2}(undef,2,3)
Out[0]:
2×3 Matrix{Int8}:
 0  0  0
 0  0  0
In [ ]:
Array{Int8,3}(undef,3,3,3)
Out[0]:
3×3×3 Array{Int8, 3}:
[:, :, 1] =
 1  0  0
 0  0  0
 0  0  8

[:, :, 2] =
 0  0     0
 0  0   -48
 0  0  -126

[:, :, 3] =
 77  127    16
 83    0  -125
 27    0    77

И вот ответ на то, почему range в Julia поддерживает интерфейс массива:

In [ ]:
1:0.5:1000 isa StepRangeLen <: AbstractArray
Out[0]:
true

Т.е параметрический тип схож на шаблоны в C++.

Но важно понимать особенности типов в Julia

Параметрические типы в Julia являются инвариатными

In [ ]:
 Vector{Int} <: Vector{Real}


 Vector{Int} <: Vector{<:Real}


 Vector{Complex} <: Vector{<:Real}


 Vector{Complex} <: Vector
Out[0]:
true

Вот тут нам может помочь DataType. DataType позволяет понять, является ли тип "объявленным".

Все конкретные типы являются DataType.

  1. Большинство непараметрических типов являются DataType
abstract type Number end;
  1. Если мы указали параметры, то это тоже DataType.

Чтобы понять, что такое DataType - проще отталкиваться от того, что не является DataType.

In [ ]:
Union{Int32,Char} isa DataType
Vector{<:Real} isa DataType # тоже своего рода "объединение всех векторов, чей тип является подтипом Real"
Out[0]:
false

И как же это всё применяется ?

Множественная диспетчеризация обладает следующим приоритетом:

  1. Конкретный тип
  2. Абстрактный тип
  3. Параметризованные тип

Закончим мы тем, что посмотрим на то, как устроена функция набольшего общего кратного, где есть "перегруженные" функции:

#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 - "шаблонную" версию, где уже будет вызвана ошибка.

Это похоже на правило

In [ ]:
promote(2, 2//3)
Out[0]:
(2//1, 2//3)

До скорых встреч!