Zagadnienie 6: Typy
System obliczeniowy Julii, tworzenie własnych struktur danych
Typy statyczne vs hierarchiczne vs obiekty
Problem: mamy pewne wyrażenie, powiedzmy
x + y
dla znanych typów/klasy x oraz y . Trzeba wygenerować kod asemblera służący do jego przeprowadzenia.
Podejście przez typy statyczne: sprawdzamy typy T1 wartości x oraz T2 wartości y . Szukamy na liście metod, czy istnieje + dla wartości typu T1 oraz T2 . Jeżeli istnieje, kompilujemy ją. Jeżeli nie, zwracamy błąd.
• brak narzutu obliczeniowego
• bardzo mała elastyczność, trudna obsługa złożonego kodu
W powyższym przypadku musi istnieć lista metod Float64 + Float64 , Int64 + Int64 , Int64 + Float64 , Float64 + Int64 itd. itp.
Zwykle języki zawierają elementy mające łagodzić ten minus, np. C/C++ zawiera szablony, nowe wersje również type traits.
Podejście obiektowe: Obiekt x wywołuje swoistą dla swojej klasy metodę + , która sprawdza klasę y , na tej podstawie znajduje właściwą procedurę i kompiluje ją.
• ustrukturyzowanie ułatwia obsługę złożonego kodu
• jeżeli jest to dynamiczne, narzut obliczeniowy
• brak symetrii ( x wywołuje y jest w kodzie czym innym niż y wywołuje x , mimo że operacja powinna być ta sama), związana z tym trudna obsługa funkcji wielu argumentów W powyższym przypadku możemy myśleć o ogólnej klasie Number , która zawiera przy sprawdzaniu dysponuje ogólnymi procedurami w rodzaju "jeżeli x lub y są Float64 , to zrzutuj tę drugą na Float64 i wywołaj Float64 + Float64 ).
Podejście przez typy hierarchiczne: sprawdzamy typy T1 wartości x oraz T2 wartości y . Szukamy na liście metod, czy istnieje + dla wartości typu T1 oraz T2 . Jeżeli istnieje, kompilujemy ją. Jeżeli nie istnieje skaczemy wyżej w drzewie typów i szukamy metody ponownie, aż znajdziemy lub dojdziemy do wierzchołka drzewa.
• brak narzutu obliczeniowego
• symetria, łatwa obsługa funkcji wielu argumentów
• mniejsze możliwości ustrukturyzowania kodu
W powyższym przypadku możemy mieć zapisane konkretne metody Float64 + Float64, Int64 + Int64 dla najprostszych przypadków, ale poza tym Number + Number czy Real + Real , które zajmują się bardziej złożonymi.
Kluczowa różnica między podejściem obiektowym a hierarchicznym:
W podejściu obiektowym metody są podczepione do klas, w przypadku typów hierarchicznych są od nich niezależne
Tworzenie typów
W Julii jako użytkownicy mamy dostęp do tworzenia typów na wszystkich poziomach.
Uwaga! Typy możemy dynamicznie tworzyć, ale nie możemy ich dynamicznie modyfikować.
Jeżeli chcemy zmienić definicję jakiegoś typu musimy zrestartować Julię.
Typy podstawowe
Definiujemy ciąg bitów zadanej długości oraz jego miejsce w drzewie typów który będzie miał własne metody przetwarzania. Składnia:
primitive type NazwaTypu <: Nadtyp n end
• n - ilość bitów
• Nadtyp - gdzie go podczepiamy, bez podania domyślnie to <: Any
Tak powstały typ nie ma żadnych metod poza operacjami bitowymi, trzeba je zapewnić samemu.
MethodError: no method matching MyInt72() Closest candidates are:
(::Type{T})(::AbstractChar) where T<:Union{AbstractChar, Number} at char.jl:
50
(::Type{T})(::Base.TwicePrecision) where T<:Number at twiceprecision.jl:243 (::Type{T})(::Complex) where T<:Real at complex.jl:37
...
Stacktrace:
[1] top-level scope @ In[3]:1
[2] eval
@ .\boot.jl:360 [inlined]
[3] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::Strin g, filename::String)
In [1]: primitive type MyInt72 <: Integer 72 end
In [3]: x = MyInt72()
@ Base .\loading.jl:1094
5-element Vector{MyInt72}:
MyInt72(0x07000000001b899e01) MyInt72(0x000000000000000001) MyInt72(0x1c0000000000000089) MyInt72(0x9c000000000fe34c00) MyInt72(0x000000000000000020)
Typy abstrakcyjne
Defniujemy nazwę typu i jego pozycję w drzewie typów. Składnia jest podobna:
abstract type NazwaTypu <: Nadtyp end
Taki typ nie zawiera żadnej innej informacji, jest to etykieta porządkująca strukturę innych typów.
true
true
false
Integer
├─ Bool
├─ MyInt72
├─ MyInts
├─ Signed
│ ├─ BigInt
│ ├─ Int128
│ ├─ Int16
│ ├─ Int32
│ ├─ Int64
│ └─ Int8
└─ Unsigned ├─ UInt128
In [4]: x = Vector{MyInt72}(undef,5) # zaalokowane 5 zmiennych MyInt72, 5 x 72 bity
Out[4]:
In [6]: isabstracttype(Number)
Out[6]:
In [8]: isabstracttype(Real)
Out[8]:
In [10]: isabstracttype(Float64)
Out[10]:
In [11]: abstract type MyInts <: Integer end
In [13]: using AbstractTrees
AbstractTrees.children(T::Type) = subtypes(T) print_tree(Integer)
├─ UInt16 ├─ UInt32 ├─ UInt64 └─ UInt8
Integer
├─ Bool
├─ MyInt72
├─ MyInts
│ ├─ MyInt1
│ └─ MyInt2
├─ Signed
│ ├─ BigInt
│ ├─ Int128
│ ├─ Int16
│ ├─ Int32
│ ├─ Int64
│ └─ Int8
└─ Unsigned ├─ UInt128 ├─ UInt16 ├─ UInt32 ├─ UInt64 └─ UInt8
Unie typów
Wspólna etykieta na wartość mogącą być ze skończonej ilości typów Składnia: Union{T1,T2,...}
Julia automatycznie tworzy unie typów dla funkcji w prostych przypadkach.
O unii typów Union{A,B, ...} możemy myśleć jako o alternatywie w rodzaju
if typeof(x) == A ...
elseif typeof(x) == B ...
elseif ...
Różnica jest taka, że unia typów daje kompilatorowi pełną informację, co pozwala na lepszą optymalizację.
Są 2 szczególnie przydatne przypadki unii typów:
• Union{T,Nothing}
• Union{T,Missing}
Jest tylko jedna zmienna o typie Nothing , która nazywa się nothing , podobnie z missing . In [15]: abstract type MyInt1 <: MyInts end
abstract type MyInt2 <: MyInts end print_tree(Integer)
Obydwa reprezentują sytuację, gdy czegoś brakuje. Różnica:
• nothing - wynik działania funkcji, która nic nie zwraca
• missing - luki w ciągach danych rzeczywistych/symulacyjnych
MethodError: no method matching sin(::Nothing) Closest candidates are:
sin(::Float16) at math.jl:1159 sin(::ComplexF16) at math.jl:1160
sin(::Complex{T}) where T at complex.jl:831 ...
Stacktrace:
[1] top-level scope @ In[16]:2
[2] eval
@ .\boot.jl:360 [inlined]
[3] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::Strin g, filename::String)
@ Base .\loading.jl:1094
missing
10-element Vector{Union{Missing, Float64}}:
1.0 missing missing 2.0 missing missing missing missing missing missing
10-element Vector{Union{Missing, Float64}}:
1.0 missing missing 4.0 missing missing In [16]: x = nothing
sin(x) # nie ma to sensu
In [18]: y = missing sin(y)
Out[18]:
In [19]: v = Vector{Union{Float64,Missing}}(missing,10) v[1] = 1.
v[4] = 2 v
Out[19]:
In [20]: v .^2
Out[20]:
missing missing missing missing
Przykład: kodowanie DNA
Chcemy zapisywać i przetwarzać informację zapisaną w DNA zapewniając, że pojedynczy kodon może przyjmować jedynie wartości A, C, T, G.
Proponowane rozwiązania:
1. Skorzystać z pakietu BioSequences .
2. Zapisać w postaci String lub Vector{Symbol} i ręcznie pilnować poprawności elementów.
3. Stworzyć BitArray{undef,2,n} i zapisywać wartości używając kolejnych kolumn.
4. Skorzystać z systemu typów Julii.
Omówimy 4.
Union{A, C, G, T}
(A(), C(), T(), G())
5-element Vector{Any}:
A() A() C() C() G()
10-element Vector{Union{A, C, G, T}}:
A() A() A() A() A() A() A()
In [21]: struct A end struct C end struct T end struct G end
Nucl = Union{A,C,T,G}
Out[21]:
In [22]: a, c, t, g = A(), C(), T(), G()
Out[22]:
In [25]: v1 = [a, a, c, c, g]
Out[25]:
In [26]: v = Vector{Nucl}(undef,10)
Out[26]:
A() A() A()
G()
0
Uwaga dotycząca wydajności
Tablice z komórkami wielu typów:
• komórki typu abstrakcyjnego, np Vector{Integer} są niewydajne
• komórki o uniach typów, np. Vector{Union{Bool,Int64}} są wydajne W razie wątpliwości można sprawdzić za pomocą komendy isabstracttype .
Komórki o uniach typów abstrakcyjnych, np. Matrix{Union{Float64,Real}} , też są niewydajne.
10000-element Vector{Float64}:
0.7358591599700428 0.014375881802169976 0.36556594584998403 0.4399182553595271 0.992018298370323 0.05026520919053867 0.16609389593603208 0.7757112142663602 0.4784123177504376 0.028798336481295816 0.8205618188524093 0.868224771022073 0.05188028745283835 ⋮ 0.6061537236056294 0.27537203134726385 0.4033707283819574 0.47696357791391475 0.2644580506518601 0.031017495180225785 0.5430989079895059 0.2346104160769107 0.22228266703684096 In [27]: v[1] = a
v[2] = c v[3] = g
Out[27]:
In [28]: v # może zawierać jedynie a lub c lub t lub g sizeof(v) # komórki nie zawierają nic
Out[28]:
In [31]: using BenchmarkTools
v = rand(10^4)
Out[31]:
0.37107225660752663 0.06743860490091813
10000-element Vector{Real}:
0.7358591599700428 0.014375881802169976 0.36556594584998403 0.4399182553595271 0.992018298370323 0.05026520919053867 0.16609389593603208 0.7757112142663602 0.4784123177504376 0.028798336481295816 0.8205618188524093 0.868224771022073 0.05188028745283835 ⋮
0.6061537236056294 0.27537203134726385 0.4033707283819574 0.47696357791391475 0.2644580506518601 0.031017495180225785 0.5430989079895059 0.2346104160769107 0.22228266703684096 0.37107225660752663 0.06743860490091813 0.7218154217943744
76.700 μs (5 allocations: 78.27 KiB) 10000-element Vector{Float64}:
0.6712242593484609 0.014375386639675606 0.3574779007502097 0.4258655050435514 0.8371316949129257 0.050244045258017615 0.16533127153258093 0.7002240047681856 0.4603703280274528 0.028794356024212476 0.7315289990529137 0.7631830184870745 0.05185701739707619 ⋮
0.5697106376007528 0.27190497049872553 0.392520770617107 0.45908375993850786 0.26138619846081407 0.03101252184162462 0.5167914790029935 0.23246409189969827 0.2204567015352551
In [34]: v2 = Vector{Real}(undef,10^4) # mniej informacji v2 .= v
Out[34]:
In [35]: @btime sin.(v)
Out[35]:
0.3626149180493014 0.06738749844985398
536.500 μs (19502 allocations: 383.12 KiB) 10000-element Vector{Float64}:
0.6712242593484609 0.014375386639675606 0.3574779007502097 0.4258655050435514 0.8371316949129257 0.050244045258017615 0.16533127153258093 0.7002240047681856 0.4603703280274528 0.028794356024212476 0.7315289990529137 0.7631830184870745 0.05185701739707619 ⋮ 0.5697106376007528 0.27190497049872553 0.392520770617107 0.45908375993850786 0.26138619846081407 0.03101252184162462 0.5167914790029935 0.23246409189969827 0.2204567015352551 0.3626149180493014 0.06738749844985398 0.6607484291422999
Typy złożone
Najbliższy odpowiednik klas w Julii. Podajemy nazwę typu, opcjonalnie nadtyp, oraz pola typu.
Składnia
Składnia definiowania:
struct NazwaTypu <: Nadtyp pole1::T1
pole2::T2 ...
end
Utworzone w ten sposób typy mają domyślny konstruktor
NazwaTypu(pole1::T1, pole2::T2, ...)
Dodatkowo utworzony zostaje też konstruktor pobierający inne typy argumentów o ile konwersja jest bezstratna.
In [36]: @btime sin.(v2)
Out[36]:
Inne konstruktory możemy tworzyć tak samo jak każdą inną metodę.
MyStr(42, 2.5, 'c')
Dla każdego typu dostęp do pól jest standardowy x.a, x.b, x.c itd.
42
2.5
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)
MyStr(1, 2.0, 'c')
InexactError: Int64(2.5) Stacktrace:
[1] Int64
@ .\float.jl:723 [inlined]
[2] convert
@ .\number.jl:7 [inlined]
[3] MyStr(x::Float64, y::Float64, c::Char) @ Main .\In[37]:2
[4] top-level scope @ In[43]:1
[5] eval
@ .\boot.jl:360 [inlined]
[6] include_string(mapexpr::typeof(REPL.softscope), mod::Module, code::Strin g, filename::String)
@ Base .\loading.jl:1094
Uwaga dotyczaca wydajności
In [37]: struct MyStr x::Int64 y::Float64 c::Char end
In [38]: str = MyStr(42,2.5,'c')
Out[38]:
In [39]: str.x
Out[39]:
In [40]: str.y
Out[40]:
In [41]: str.c
Out[41]:
In [42]: str2 = MyStr(1,2,'c') # 2 nie jest typu Float64, zrzutowane na Float64
Out[42]:
In [43]: str = MyStr(2.5,1.,'d') # nie jest jasne, czego oczekujemy po 2.5
Typy złożone o polach typu abstracyjnego są niewydajne.
Typ w rodzaju
struct X n::Integer end
jest (generalnie) złym pomysłem. Lepiej
struct X n::Int64 end
Jeżeli potrzebujemy większej elastyczności, używamy typów parametrycznych
Typy parametryczne
Typy parametryczne UnionAll znacząco uelastyczniają drzewo typów oraz ich obsługę. Jak sama nazwa wskazuje są to typy zależne od jednego lub więcej parametrów. Parametrami tymi mogą być inne typy lub wartości typu prostego ( Bool , Int64 , Char , itp.).
Składnia: nawiasy wąsate po nazwie typu, opcjonalnie nadtyp parametru
struct X{T}
...
end ogólniej
struct X{T <: S}
...
end
Powrót do przykładu z wyżej
struct X{T <: Integer}
n::T end
jest wydajny. W zależności od konkretnej potrzeby będziemy mieli do dyspozycji X{Int64}, X{Int32}, X{BigInt} itd. oraz metody przetwarzania.
In [44]: struct Point2D{T <: Real}
x::T y::T end
Point2D{Int64}(2, 3)
Point2D{Float64}(3.0, 4.5)
Point2D{BigFloat}(100.0, 2.0)
(100.0, 2.0)
Metody parametryczne
Możemy tworzyć metody, które będą obsługiwały ogólny zestaw przypadków dla typu parametrycznego. Składnia
metoda(argumenty) where T <: S
metoda(argumenty) where {T1 <: S1, T2 <: S2}
Jak zawsze domyślne jest <: Any .
Point2D{Int64}(2, 3)
Point2D{Int64}(3, 3)
Point2D{Int64}(5, 6) In [46]: x = Point2D(2,3)
Out[46]:
In [47]: y = Point2D(3.0,4.5)
Out[47]:
In [50]: z = Point2D(big"100.0",big"2.0")
Out[50]:
In [52]: z.x, z.y
Out[52]:
In [57]: Base.:+(p1::Point2D{T},p2::Point2D{T}) where T <: Real = Point2D(p1.x+p2.x,p2.
In [59]: x
Out[59]:
In [60]: x2 = Point2D(3,3)
Out[60]:
In [61]: x + x2
Out[61]:
In [62]: Base.:+(p1::Point2D{T},p2::Point2D{S}) where {T <: Real, S <: Real} = Point2D(
Point2D{Float64}(5.0, 7.5)
In [63]: x + y # Point{Int64} + Point{Float64}
Out[63]:
In [ ]: