前置き
色々やりたいことはあるのに思うように捗りません1。
そんなときは現実逃避のために、Julia の最新開発版をインストールして新機能を試してみて近未来を覗いたりします2。
v0.6.0-dev に、前々から少し期待していた「型システムの仕様変更」がついに入りました!
割と自分の思っていた方向に実装がされていたので、紹介したいと思います。
【2018/02/19 11:45】編集:参考リンク集のリンク先を一部修正3。
Julia の型システム概要
Julia は、動的型付言語です。コンパイル時ではなく基本的に実行時に型が決定します。
一方で、関数の引数や戻り値等に、型アノテーションを付けることはできます。
特に関数については、引数の違い(型及び個数)で同じ関数を多重定義することができ、実行時に適切な引数を持った関数が呼ばれる仕組みを取っています(多重ディスパッチ と言います)。これが(おそらく)Julia の最大の特徴。
また他言語の総称型のように、型パラメータ を取ることのできる型を宣言することもできます。
標準では、例えば Array
型は2つの型パラメータを取ります。
julia> Array
Array{T,N}
T
は要素の型、N
は次元数(つまり型ではなく整数値)です。
例えば Int
の1次元配列は Array{Int,1}
型になります。
julia> isa([1, 2, 3], Array{Int, 1})
true
julia> isa([1, 2, 3], Array{Int, 2})
false
julia> isa([1, 2, 3], Array{Float64, 1})
false
こんな感じ。要素の型・次元数どちらかが異なれば isa()
は false
を返します。
あとより後方の型パラメータは省略でき、省略されるとより「制限が緩くなる」感じになります。
具体的には、↓が成り立ちます。
julia> Array{Int,1} <: Array{Int} <: Array
true
julia> isa([1, 2, 3], Array{Int})
true
julia> isa([1, 2, 3], Array)
true
演算子 <:
は、A <: B
で「A
が B
の subtype(同一の型でもOK)」の時に true
となります。issubtype(A, B)
と書いても同じ(エイリアス)です。
また例えば関数の引数の型やその型パラメータに、型制約 を付けることも可能です。
julia> function hoge{T<:Integer}(a::Array{T})
:ok
end
hoge (generic function with 1 method)
julia> hoge([1, 2, 3])
:ok
julia> hoge([1, big"2", UInt8(3)])
:ok
julia> hoge([0.1, 0.2, 0.3])
ERROR: MethodError: no method matching hoge(::Array{Float64,1})
Closest candidates are:
hoge{T<:Integer}(::Array{T<:Integer,N}) at REPL[XX]:2
この例は引数を a::Array{T}
とし、関数名と()
の間に {T<:Integer}
と書く4ことで、「引数は『要素が(何らかの)整数型である配列』」という意味になり、そうであれば関数が実行され、そうでなければ「マッチするメソッドがない」というエラーになっています。
Julia の型システムの基本について、少し詳しいことが、2016年の JuliaAdventCalendar で紹介されています(「Juliaのtype systemの基本についてざっくりと」)。こちらも参照してください。
v0.6 で追加される(た)新しい型関連の機能・文法
Julia v0.6 の型システムの仕様変更は、主に先述の 型パラメータ(型制約) に関するものとなります。
簡単にまとめると、以下の2点:
- 型制約をつけるときに、同じ
{~}
内で前方で宣言された型パラメータを参照できるようになった。 -
where
を用いた型制約の記述(例:Array{T} where T <: Integer
)ができるようになった。
順に説明していきます。
型パラメータの(後方)参照5
これが欲しかった!
関数宣言時や型定義時に、同じ {〜}
内で前方で宣言した型パラメータを再利用することができるようになります(ました)。なぜ今までできなかった!
例えば、こんな風に書けます。
type Hoge{T<:Integer, A<:AbstractArray{T}}
x::T
a::A
end
つまり。
ある整数型の値 x
と、それと同じ型を要素として持つある配列型の値 a
をメンバとして持つ型を定義しています。
ごくごく自然な記述ですよね?
なのにこれを julia v0.5.0 以前で実行しようとすると ERROR: UndefVarError: T not defined
のようなエラーになります6。何度これで「うがー」と唸ったことか。
用例1:型パラメータのネスト
何が嬉しいかって、「型パラメータのネスト」が扱いやすくなる(なった)んです。
こんな例を考えてみましょう。
まずは、型パラメータを持つ型を階層構造で定義します。
# v0.5.0, v0.6.0 共通
abstract AbstractTypeParam{T}
type TypeParam1{T} <: AbstractTypeParam{T}
t::T
end
type TypeParam2{T} <: AbstractTypeParam{T}
t::T
end
そして、(その型の値(インスタンス)ではなく)「その型そのもの」を引数で受け取って、その型パラメータ(が表す型)を返してほしい、という要望があるとします。
julia> getparamtype(TypeParam1{Int})
Int64
julia> getparamtype(TypeParam2{String})
String
見るからに簡単に実装出来そうですよね?
でもどう実装すれば良いか?
julia v0.6.0 なら、たった1行で実現できます↓。
getparamtype{T,TP<:AbstractTypeParam{T}}(::Type{TP}) = T
一見複雑ですが、とにかくコレ1行だけ記述すれば要求を満たせます。
同じことを v0.5.0 以前でやろうとすると。
まず「同じ {〜}
の中で、前方で宣言した T
を参照している」ので、そのままでは先ほどと同様、ERROR: UndefVarError: T not defined
というエラーになります。
では、↓こうは書けないか?と思うかもしれません。
getparamtype{T,TP<:AbstractTypeParam}(::Type{TP{T}}) = T
# @> ERROR: TypeError: Type{...} expression: expected Type{T}, got TypeVar
これは↑のようなエラーになります7。これ、分かりにくいですが、エラーが起きているのは TP{T}
の部分です。TP
というのが(宣言時においてはまだ)型ではなく TypeVar
と呼ばれるものになっており、型パラメータを取ることができないからです。
ではどうすれば良いか。
まず、↓について考えてみましょう。
getparamtype{T}(::Type{AbstractTypeParam{T}}) = T
これは getparamtype(AbstractTypeParam{Int})
とした場合には正しく Int64
が返ります。しかし AbstractParamType
を継承した型を渡して、例えば getparamtype(TypeParam1{Int})
を実行しようとしても、ERROR: MethodError …
というエラーになってしまいます。
なぜなら、↓だからです。
julia> isa(AbstractTypeParam{Int}, Type{AbstractTypeParam{Int}})
true
julia> isa(TypeParam1{Int}, Type{AbstractTypeParam{Int}})
false
つまり引数の型が合わないから受け取れない(受け取れる関数が多重定義されていない)からです。
ならば、それを多重定義すれば良い。つまり取り敢えずこうすれば要求は満たせます:
getparamtype{T}(::Type{AbstractTypeParam{T}}) = T
getparamtype{T}(::Type{TypeParam1{T}}) = T
getparamtype{T}(::Type{TypeParam2{T}}) = T
そう、必要な型に対して関数を全部記述すれば。
……。
センスないですよね!
それに「AbstractTypeParam
を継承した新しい型(例:TypeParam3
)を定義したら、それに合わせて getparamtype()
関数も多重定義する(例:getparamtype{T}(::Type{TypeParam3{T}}) = T
)」必要があります。
めんどくさいですよね! 全然 DRY じゃないですよね!
じゃ、こうすれば DRY に書けます。
getparamtype{T}(::Type{AbstractTypeParam{T}}) = T
getparamtype{TP<:AbstractTypeParam}(::Type{TP}) = getparamtype(supertype(TP))
つまり、AbstractTypeParam
の subtype が渡ってきたら、その supertype を取得して、再帰呼び出しする。ということ。例えばこの場合、supertype(TypeParam1{Int}) == AbstractTypeParam{Int}
なので、最終的に getparamtype{T}(::Type{AbstractTypeParam{T}}) = T
が呼ばれて T(=Int64)
が返る、という仕組み。
……。
うん。やっていることは追えば分かるけれど、割とトリッキーなコードですよね。非常に見通しが悪い。
元々何がしたいんでしたっけ? このコードを見てそれを説明できますか?
改めてやりたいこと(仕様)をもう一度、日本語(自然言語)を使って表現してみると、こうなります。
「何かしらの型 T
があって、それを型パラメータとして持つ、ある AbstractTypeParam
の subtype(=○ <: AbstractTypeParam{T}
となるような○
)を引数に渡したときに、T
を返す」
そこで先ほどの(v0.6.0 用の)コードを、もう一度見てみましょう。
getparamtype{T,TP<:AbstractTypeParam{T})(::Type{TP}) = T
ほら! 今日本語で書いた仕様をそのまま記述できてるじゃないですか!
というように、型パラメータを持つ型をネストしたときに、その型パラメータを非常に扱いやすくなり、より直感的かつ簡潔に書けるようになる(なった)んです。ようやくなった! なぜ今までできなかった!
用例2:共変
先述の通り、Julia は動的型付けなので、変数に型は存在しません(実行時には型は確定している)。
ただ関数の引数に型アノテーションを付けることはできるので、引数で受け取った値(仮引数の値)は定義時にほぼ確定します。
またその時に型パラメータ(例:fuga{T}(x::T) = 〜
)を利用すれば、その型を T
という定数(扱いの変数?)で受け取ることができます(先ほどの例でもそれを利用しています)。
ところで、例えば、以下のような関数を考えてみます。
fuga{T}(x::T,xs::AbstractArray{T}) = (typeof(x),typeof(xs),T)
これは以下のような結果になります。
julia> fuga(1, [2,3,4])
(Int64,Array{Int64,1},Int64)
julia> fuga0(big"1", [2,3,4])
ERROR: MethodError: no method matching fuga0(::BigInt, ::Array{Int64,1})
Closest candidates are:
fuga{T}(::T, ::AbstractArray{T,N} where N) at REPL[78]:1
julia> fuga(1.0, [1,2,3])
ERROR: MethodError: no method matching fuga0(::Float64, ::Array{Int64,1})
Closest candidates are:
fuga{T}(::T, ::AbstractArray{T,N} where N) at REPL[78]:1
julia> fuga(1, Integer[2,3,4])
(Int64,Array{Integer,1},Integer)
字面的には、「第2引数の配列の要素の型(第1型パラメータ)と、第1引数の型が一致しているとき」に限って実行されそうに見えます。実際、最初の3つの例は「Int64
とInt64
→○」「Int64
とBigInt
→×」「Int64
とFloat64
→×」となっています。
しかし実際には、最後の例もエラーにならず実行されます。「Integer
とInt64
→○」です。
ただ落ち着いて考えれば、これは分かると思います。まず Array{Integer}
という配列型は、要素の型は Integer
なので、T
として Integer
が当てはまります。そうすると Int64
は Integer
の subtype(Int64 <: Signed <: Integer
なので descendent type と言うべき?)なので、x::T
という引数で Int64
の値を受け取ることができる、ということです。
ただこのとき、T
は「第2引数xs
(配列)の要素の型」は直接表していても、「第1引数x
の型」を直接は表していないことになります。実行時に型は確定していますが、関数定義時には「x
はT
型の subtype」という情報しかないことになります。つまり T
と x
の実際の型が一致するとは限りません。typeof(x) == T
だと思い込んで T
を利用したコードを書こうとしたときに想定外の動作をするかもしれません8。
そこで、以下のような関数を考えてみましょう(Julia 0.6 以降)。
fuga2{S,T<:S}(x::T,xs::AbstractArray{S}) = (typeof(x),typeof(xs),T,S)
先ほどと同じ引数で実行してみると↓。
julia> fuga2(1, [1,2,3])
(Int64,Array{Int64,1},Int64,Int64)
julia> fuga2(big"1", [1,2,3])
ERROR: MethodError: no method matching fuga2(::BigInt, ::Array{Int64,1})
Closest candidates are:
fuga2{S,T<:S}(::T<:S, ::AbstractArray{S,N} where N) at REPL[96]:1
julia> fuga2(1.0, [1,2,3])
ERROR: MethodError: no method matching fuga2(::Float64, ::Array{Int64,1})
Closest candidates are:
fuga2{S,T<:S}(::T<:S, ::AbstractArray{S,N} where N) at REPL[96]:1
julia> fuga2(1, Integer[1,2,3])
(Int64,Array{Integer,1},Int64,Integer)
最初の3つは、動作状況も結果も fuga
と全く同様です。
最後。T
は引数 x
の型(Int64
)に一致し、S
は引数 xs
の配列の要素の型(Integer
)に一致しています。
これなら安心して、T
を信用して扱うことができますね。
と同時に、先ほど「落ち着いて考え」たことを、考えなくても「コードを見れば分かる」ようにもなっています。
「第1引数 x
の型(T
)は、第2引数 xs
の要素の型(S
)の subtype」と、関数定義のコードに記述されていますよね?
このような「コードを見ればその意図が分かる」ような記述が、実はできるようになる(なった)、ということです。
where
を利用した型制約の記述
型制約を記述するのに、新しい文法として以下のように記述できるようになります(ました)。
Array{T} where T <: Integer
関数定義時にも記述することができ、例えば前節までの例を書き換えると以下のようになります。
function hoge_(a::Array{T}) where T <: Integer
:ok
end
getparamtype_(::Type{TP}) where TP<:AbstractTypeParam{T} where T = T
fuga2_(x::T,xs::AbstractArray{S}) where T<:S where S = (typeof(x),typeof(xs),T,S)
関数定義の場合は、関数名と引数列の(
の間の {}
に記述していた内容を、引数列の )
の後に where 〜
を並べる形になります(順番注意)。
なお関数定義時に型制約を付ける目的でこの記述を利用した場合は、内部的には従来通り {〜}
内に記述した場合と何ら変わりはない模様です。
julia> methods(hoge)
# 1 method for generic function "hoge":
hoge{T<:Integer}(a::Array{T,N} where N) in Main at REPL[XX]:1
julia> methods(hoge_)
# 1 method for generic function "hoge_":
hoge_{T<:Integer}(a::Array{T,N} where N) in Main at REPL[XX]:2
用例:上限境界・下限境界
こちらの記事 にもある通り、この記法はそのまま サブタイピング に利用できます。
つまり、型そのものにある制約を設け、その「制約を持った型」と他の型との間の継承関係を作ることができます。
まず、「型制約なし」の場合。
julia> Array{T} where T # 型制約なし
Array{T,N} where N where T
julia> Array{Int} <: Array{T} where T
true
julia> Array{Float64} <: Array{T} where T
true
これは「Array
の(第1)型パラメータ T
として、特に制約を設けていない」ということ。この場合、要素の型がどのような Array
も subtype として扱えます。
これは実は、(少なくとも Julia v0.6.0 においては)Array{Int} <: Array
と何ら変わりません。というか、(↑の例を見れば分かる通り)Array{T} where T
は Array{T,N} where N where T
の省略形であるのと同様に、Array
も Array{T} where T
の省略形(ひいては Array{T,N} where N where T
の省略形)と見なせます。例えば Array <: (Array{T} where T) <: Array
は true
を返します。
この記述方法で「使える」のは、「上限境界」と「下限境界」の用例です。
上限境界
上限境界 とは、「supertype に境界を設ける」ということです。言い換えると、指定した型もしくはその subtype を許可する、という制約です。
例を示します。
julia> Array{T} where T <: Integer # 型制約(上限境界)あり
Array{T,N} where N where T<:Integer
julia> Array{Int} <: Array{T} where T <: Integer
true
julia> Array{Float64} <: Array{T} where T <: Integer
false
julia> Array{Real} <: Array{T} where T <: Integer
false
Array{T} where T <: Integer
は「Integer
の何らかの subtype を要素の型とする配列型」という意味になります。
Int64 <: Integer
なので Array{Int}
は Array{T} where T <: Integer
の subtype となります。
一方 Integer <: Real
であるため、最後の例は false
になります。
また Float64
は Integer
と直接的な継承関係にないので、やはり false
です。
下限境界
下限境界 は 上限境界の逆で、「subtype に境界を設ける」ということです。言い換えると、指定した型もしくはその supertype を許可する、という制約です。
例を示します。
julia> Array{T} where T >: Int # 型制約(下限境界)あり
Array{T,N} where N where T>:Int64
julia> Array{Int} <: Array{T} where T >: Int
true
julia> Array{Float64} <: Array{T} where T >: Int
false
julia> Array{Real} <: Array{T} where T >: Int
true
Array{T} where T >: Int
9 は「Int
の何らかの supertype を要素の型とする配列型」という意味になります。
Int <: Int
なので Array{Int}
は Array{T} where T >: Int
の subtype となります。
Float64
は Int
と直接的な継承関係にないので、Array{Float64} <: Array{T} where T >: Int
やはり false
です。
一方、Int <: Real
であるため、最後の例は true
になります。Array{Any} <: Array{T} where T >: Int
も true
です。
ちなみに、ここに出てきた >:
は関数宣言時や型宣言時等の型制約でも利用することができて、例えば以下のように書くこともできます:
fuga2__{T,S>:T}(x::T,xs::AbstractArray{S}) = (typeof(x),typeof(xs),T,S)
ただしこの場合、先ほどまで(fuga2
や fuga2_
)と結果が少しだけ異なります。
ここでは深入りしませんが、興味のある方は実際に試してみてください。
あと、上限下限境界両方を指定することもできます。
julia> Array{T} where Int <: T <: Integer # 型制約(上限下限境界)あり
Array{T,N} where N where Int64<:T<:Integer
こちらも具体的な動作例は省略します。
v0.5.0 以前の上限・下限境界
実は余り知られていませんが10、v0.5.0(以前)でも、型制約(上限・下限境界)を設けることは実はできます。
以下は v0.5.0 での動作です:
上限境界:
julia> Array{TypeVar(:T,Integer)}
Array{T<:Integer,N}
julia> Array{Int} <: Array{TypeVar(:T,Integer)}
true
julia> Array{Float64} <: Array{TypeVar(:T,Integer)}
false
julia> Array{Real} <: Array{TypeVar(:T,Integer)}
false
下限境界:
julia> Array{TypeVar(:T,Int,Any)}
Array{Int64<:T,N}
julia> Array{Int} <: Array{TypeVar(:T,Int,Any)}
true
julia> Array{Float64} <: Array{TypeVar(:T,Int,Any)}
false
julia> Array{Real} <: Array{TypeVar(:T,Int,Any)}
true
上限下限境界(動作例省略):
julia> Array{TypeVar(:T,Int,Integer)}
Array{Int64<:T<:Integer,N}
軽く試したところ、v0.4.7, v0.3.12 でも同様の動作となりました。
また v0.6.0 でも、これらのコードはエラーにはなりませんでしたが、動作結果は異なるものとなりました。元々直感的な記述ではないですし、意味が大きく変わったようです。
ということで、これからは v0.6.0 以降の where
を使った記述だけを考えるようにしましょう。
まとめ
Julia の型システム、当初から割としっかり考えられており、一貫性のあるものでした。
v0.6.0 での新型システムは、それをほぼ互換性を保ったまま、より柔軟に、というより正確にはより直感的かつ簡潔な記述ができるように、進化した・洗練された感じです。
v1.0 の正式リリース前に、この新仕様が入れられたこと。すごく良かったと思います。
v0.6.0、そして v1.0 の正式リリースが今から楽しみ♪
参考
- New language features - Julia v0.6.0 Release Notes
-
Julia Documentation » More about types
- More about types (v0.5)(for v0.5.x)
- More about types (v0.6)(for v0.6.0)
- Issues · JuliaLang/julia
- Julia Advent Calendar 2016
-
名古屋Ruby会議03 に向けて tensorflow.rb を試そうと思ったのですがインストールはできたのに
Session.new
で segmentation fault …うがー ↩ -
過去にも勉強が捗らないときに「Julia 0.5-dev の Generator に触れてみた。」という記事を書いています。 ↩
-
NEWS.md とか manual とかはどんどん新しい情報に書き換わるので、そこにリンクしておいた箇所はたまにこういうメンテ(=正しいリンク先(=古い情報が残っているページ)へリンクを変更)する必要がありますよね。気付いたらコメントなり編集リクエスト欲しい(今回は自分で気付いた)。 ↩
-
この場合の
<:
はBool
を返す演算子ではなく、「T
はInteger
の subtype」ということを表すだけの文法的な記述です。またこのときのT
は、宣言時においては特定の型を表すものではなくTypeVar
と呼ばれるものになります。 ↩ ↩2 -
普通「後方参照」という用語は、正規表現においてマッチした部分文字列をそのあとで番号等で参照(例:
r"([abc])n\1"
は"ana"
にマッチ)することを指す言葉です。なので厳密には同じ概念ではありませんが、言葉として便利なのでこの記事では便宜的に「(後方)参照」という言葉を使います。もし正しい用語とか「こう言い換えられるよ」という Suggest があればコメントor編集リクエストください。 ↩ -
エラーの種類や内容はバージョンによって異なりますが、意味としては大体同じエラーになるはず。 ↩
-
ちなみにこれは v0.5.0 のエラーで、v0.6.0 では
ERROR: TypeError: Type{...} expression: expected UnionAll, got TypeVar
と、メッセージが少し異なります。 ↩ -
例えば
one(Float64)
は1.0
(Float64
)となりますが、one(Real)
(Float64 <: AbstractFloat <: Real
)は1
(Int64
)となります。fuga(1.0, Real[2.0,3.0,4.0])
と言うコードはT==Real
となるので、浮動小数だと仮定してT
を利用したコードを書こうとするときっとハマります。 ↩ -
この場合の
>:
は、先ほどの 型制約記述中の<:
4 と同様、「T
はInt
の supertype」ということを表す文法的な記述です。また(julia v0.6.0-dev.2763 から)>:
という演算子も追加されており、A >: B
はB <: A
と同じ意味になります。 ↩ -
私も今回調べて初めて知りました(^-^; ↩