Шпаргалка по типам Julia для инженеров и не только

от автора

Введение

Эта статья призвана помочь и тем, кто уже давно использует Julia, и тем, кто только начинает её изучать. Благо совсем скоро начнётся Зимняя школа Julia, где можно будет познакомиться с этим по-настоящему замечательным языком.

Мой путь к написанию этой статьи лежал через погружение в работу с массивами, векторами и матрицами в Engee – среде расчётов и моделирования, в которой Julia является основным языком. И в сообществе которой лежит код этой статьи.

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

v = [1      2      3]

— вектор размера (3,), а вектор-строка m = [1 2 3] — матрица размера (1,3).

И зная о том, что Julia — это крайне эффективный для вычислений язык, я допускал классические ошибки:

a = collect(1:10) for i in a     ... end

Ведь в MATLAB это было реализовано именно так (в нём range — это массив):

По прочтении этой статьи вы поймёте, почему не стоит использовать collect там, где это не нужно.

Но потом мне стало интересно, как же range, занимая всего 48 байт, обладает всем функционалом массива в виде индексации, сортировки и пр:

sizeof([2:0.5:100;])  # 1546 байт sizeof(2:0.5:100)     # 48   байт

И, погрузившись в документацию, благо она переведена на русский, я пошёл искать ответы на то, почему Julia так здорово работает с range.

В итоге «искал медь, а нашёл золото». Оказывается, система типов в Julia действительно очень интересная.

Было бы странно переписывать документацию на Хабр, поэтому

эта статья призвана собрать в одном месте и в общих чертах (но с неочевидными иногда особенностями) описать систему типов и их предназначение в 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}, AbstractDict{K,V}

  • Исходные типы: тип, определение которого содержится в 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))
tr = FilledBool(1) = 11111111 fls = FilledBool(0) = 00000000 Regular Bool true: 00000001

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

isbitstype(FilledBool) #true

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

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

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

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

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

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

  • год покорения (год может быть положительным или отрицательным);

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

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

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

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

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

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

@show sizeof(Mountain) # 2 поля по 2 байта = 4 isbitstype(Mountain)
sizeof(Mountain) = 4 true

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

Почему строка из 6 символов имеет размер 8

Строка хранится не как массив элементов 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); 
sizeof(Moscow) = 8 sizeof(Moscow.name) = 6 Base.summarysize(Moscow) = 22

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

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") объясняется здесь.

@show isbitstype(City) @show isbitstype(StaticCity);
isbitstype(City) = false isbitstype(StaticCity) = true

Хочется отдельно отметить, что

неизменяемый тип может иметь неизменяемые поля изменяемого типа.

В качестве аналогии:

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

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
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 24
pointer(Alex.grades)
push!(Alex.grades,4) Alex.grades # [5, 5, 5, 4] @show pointer(Alex.grades)
push!(Alex.grades,4) Alex.grades # [5, 5, 5, 4] @show pointer(Alex.grades)

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

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

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

try Alex.grades = [1, 2, 3] # связываем с НОВЫМ вектор catch e     e end
ErrorException("setfield!: immutable struct of type Student cannot be changed")

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

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

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
ErrorException("setfield!: const field .name of type MutableStudent cannot be changed")

Теперь мы можем менять вектор на другой:

@show pointer(Peter.grades) @show Peter.grades = [2,2,2] @show pointer(Peter.grades)
pointer(Peter.grades) = Ptr{Int64} @0x000000010a12eea0 Peter.grades = [2, 2, 2] = [2, 2, 2] pointer(Peter.grades) = Ptr{Int64} @0x000000010c152f00 Ptr{Int64} @0x000000010c152f00
Отличие неизменяемой 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
im_obj_1 === im_obj_2 = true const_mut_obj_1 === const_mut_obj_2 = false

Зачем нужны неизменяемые структуры

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

@allocations (a = Immutable(3,4); b = Immutable(3,4)))       # 0   @allocations (a = ConstMutable(3,4); b = ConstMutable(3,4))) # 2

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

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

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)) # 0 println(@allocations bar(1,2)) # 0

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

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

  • группировать конкретные типы

  • задавать интерфейсы для функций

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

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

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

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

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

Int8 <: Integer || Int16 <: Integer
true
subtypes(Signed)
6-element Vector{Any}:  BigInt  Int128  Int16  Int32  Int64  Int8

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

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

supertypes(Int8)
(Int8, Signed, Integer, Real, Number, Any)

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

using AbstractTrees AbstractTrees.children(t::Type) = subtypes(t) print_tree(Number)
Number ├─ MultiplicativeInverse │  ├─ SignedMultiplicativeInverse │  └─ UnsignedMultiplicativeInverse ├─ Complex └─ Real    ├─ AbstractFloat    │  ├─ BigFloat    │  ├─ BFloat16    │  ├─ Float16    │  ├─ Float32    │  └─ Float64    ├─ AbstractIrrational    │  └─ Irrational    ├─ Integer    │  ├─ Bool    │  ├─ Signed    │  │  ├─ BigInt    │  │  ├─ Int128    │  │  ├─ Int16    │  │  ├─ Int32    │  │  ├─ Int64    │  │  └─ Int8    │  └─ Unsigned    │     ├─ UInt128    │     ├─ UInt16    │     ├─ UInt32    │     ├─ UInt64    │     └─ UInt8    └─ Rational 

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

Чтобы проверить, указан ли тип как abstract, используйте функцию isabstracttype. Но чтобы понять — является ли тип абстрактным — используйте функцию !isconcretetype(т.е. абстрактный = не конкретный).

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

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

Поэтому в 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("Бурёнка"))
Мухтар встречает Бурёнка и здоровается.

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

Data Type

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

И пока что просто посмотрим на странного зверя — DataType, а как он работает посмотрим в конце статьи.

123 isa Integer # true Vector isa DataType || Dict isa DataType # false Function isa DataType # true

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

Параметрические типы

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

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

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

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

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

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

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

Параметрические абстрактные типы

Примером параметрического абстрактного типа может служить 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           # false ENV isa AbstractDict   # true

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

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

Несмотря на то, что реализация массивов написана на C, мы можем посмотреть, что из себя представляют

[1,2,3]  [1 2 3;  4 5 6;  7 8 9;]   и rand(3,3,3)

Всё дело в определении этого типа.

   abstract type AbstractArray{T,N} end

T здесь обозначает тип переменных, который будет храниться в массиве, а N— размерность массива. (ndims просто возвращает значение N).

Array <: DenseArray <: AbstractArray # true
4-element Vector{Int8}:  0  0  0  0
Array{Int8,2}(undef,2,3)
2×3 Matrix{Int8}:  3  0  0  0  0  0
Array{Int8,3}(undef,3,3,3)
3×3×3 Array{Int8, 3}:     [:, :, 1] =      -64  24    0       36   1    0       47   0  -64          [:, :, 2] =      36  1    0      47  0  -64      24  0   36          [:, :, 3] =      47  0  -64      24  0   36       1  0   47

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

1:0.5:1000 isa StepRangeLen <: AbstractArray  # true

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

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

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

Инвариантность параметрических типов

Инвариантность параметрических типов
Пояснение к картинке

Здесь вместо Vector стоит указывать DenseVector, так как Vector является конкретным типом, а DenseVector — абстрактным. А наследование в Julia применимо только к абстрактным типам.

DenseVector{Integer} <: DenseVector{Real}    # false DenseVector{Integer} <: DenseVector{<:Real}  # true 

 Vector{Int}     <: Vector{Real}     # false  Vector{Int}     <: Vector{<:Real}   # true  Vector{Complex} <: Vector{<:Real}   # false  Vector{Complex} <: Vector           # true

Подробнее о том, что такое Vector{<:Real} можно почитать по ссылке.

Так кто же такой DataType?

DataType позволяет понять, является ли тип «объявленным».

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

  2. Большинство не параметрических типов являются DataType

    abstract type Number end; - является DataType

  3. Если мы указали параметры, то это тоже DataType.

DenseVector{Integer} isa DataType # true Vector isa DataType               # false

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

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

Подробнее смотрите документацию по DataType и UnionAll.

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

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

  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. Для случая целочисленных будет вызвана функция 1.

  2. Если же мы передадим рациональные параметры, то вызовется функция 2.

  3. Если мы передадим lcm(2, 2//3),то сначала вызовется функция 3 и произойдёт продвижение типов. После чего вызовется функция 2.

promote(2, 2//3) # (2//1, 2//3)
  1. Но если мы вызовем lcm(2, 1.5) , то после продвижения типов мы попадём в 4 — «шаблонную» версию, где уже будет вызвана ошибка.

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


ссылка на оригинал статьи https://habr.com/ru/articles/882178/