LoginSignup
11
4

More than 5 years have passed since last update.

Julia 0.6-dev の新しい Type System に触れてみた。

Last updated at Posted at 2017-01-29

前置き

色々やりたいことはあるのに思うように捗りません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 で「AB の 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つの例は「Int64Int64→○」「Int64BigInt→×」「Int64Float64→×」となっています。

しかし実際には、最後の例もエラーにならず実行されます。「IntegerInt64→○」です。
ただ落ち着いて考えれば、これは分かると思います。まず Array{Integer} という配列型は、要素の型は Integer なので、T として Integer が当てはまります。そうすると Int64Integer の subtype(Int64 <: Signed <: Integer なので descendent type と言うべき?)なので、x::T という引数で Int64 の値を受け取ることができる、ということです。
ただこのとき、T は「第2引数xs(配列)の要素の型」は直接表していても、「第1引数xの型」を直接は表していないことになります。実行時に型は確定していますが、関数定義時には「xT型の subtype」という情報しかないことになります。つまり Tx の実際の型が一致するとは限りません。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 TArray{T,N} where N where T の省略形であるのと同様に、ArrayArray{T} where T の省略形(ひいては Array{T,N} where N where T の省略形)と見なせます。例えば Array <: (Array{T} where T) <: Arraytrue を返します。

この記述方法で「使える」のは、「上限境界」と「下限境界」の用例です。

上限境界

上限境界 とは、「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 になります。
また Float64Integer と直接的な継承関係にないので、やはり 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 >: Int9 は「Int の何らかの supertype を要素の型とする配列型」という意味になります。
Int <: Int なので Array{Int}Array{T} where T >: Int の subtype となります。
Float64Int と直接的な継承関係にないので、Array{Float64} <: Array{T} where T >: Int やはり false です。
一方、Int <: Real であるため、最後の例は true になります。Array{Any} <: Array{T} where T >: Inttrue です。

ちなみに、ここに出てきた >: は関数宣言時や型宣言時等の型制約でも利用することができて、例えば以下のように書くこともできます:

fuga2__{T,S>:T}(x::T,xs::AbstractArray{S}) = (typeof(x),typeof(xs),T,S)

ただしこの場合、先ほどまで(fuga2fuga2_)と結果が少しだけ異なります。
ここでは深入りしませんが、興味のある方は実際に試してみてください。

あと、上限下限境界両方を指定することもできます。

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 の正式リリースが今から楽しみ♪

参考


  1. 名古屋Ruby会議03 に向けて tensorflow.rb を試そうと思ったのですがインストールはできたのにSession.newで segmentation fault …うがー 

  2. 過去にも勉強が捗らないときに「Julia 0.5-dev の Generator に触れてみた。」という記事を書いています。 

  3. NEWS.md とか manual とかはどんどん新しい情報に書き換わるので、そこにリンクしておいた箇所はたまにこういうメンテ(=正しいリンク先(=古い情報が残っているページ)へリンクを変更)する必要がありますよね。気付いたらコメントなり編集リクエスト欲しい(今回は自分で気付いた)。 

  4. この場合の <:Bool を返す演算子ではなく、「TIntegerの subtype」ということを表すだけの文法的な記述です。またこのときの T は、宣言時においては特定の型を表すものではなく TypeVar と呼ばれるものになります。 

  5. 普通「後方参照」という用語は、正規表現においてマッチした部分文字列をそのあとで番号等で参照(例:r"([abc])n\1""ana" にマッチ)することを指す言葉です。なので厳密には同じ概念ではありませんが、言葉として便利なのでこの記事では便宜的に「(後方)参照」という言葉を使います。もし正しい用語とか「こう言い換えられるよ」という Suggest があればコメントor編集リクエストください。 

  6. エラーの種類や内容はバージョンによって異なりますが、意味としては大体同じエラーになるはず。 

  7. ちなみにこれは v0.5.0 のエラーで、v0.6.0 では ERROR: TypeError: Type{...} expression: expected UnionAll, got TypeVar と、メッセージが少し異なります。 

  8. 例えば one(Float64)1.0Float64)となりますが、one(Real)Float64 <: AbstractFloat <: Real)は 1Int64)となります。fuga(1.0, Real[2.0,3.0,4.0]) と言うコードは T==Real となるので、浮動小数だと仮定して T を利用したコードを書こうとするときっとハマります。 

  9. この場合の >: は、先ほどの 型制約記述中の <:4 と同様、「TIntの supertype」ということを表す文法的な記述です。また(julia v0.6.0-dev.2763 から)>: という演算子も追加されており、A >: BB <: A と同じ意味になります。 

  10. 私も今回調べて初めて知りました(^-^; 

11
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
4