型の変換と昇格

Julia には数学演算子の引数を共通の型へと昇格 (promote) させるシステムがあります。このシステムは整数と浮動小数点数数学演算と初等関数メソッドといった章で何度か触れてきました。この章では昇格システムがどのように動作するかを説明し、新しい型や組み込みの数学演算子以外の関数へ適用する方法を示します。

伝統的に、算術演算の引数の昇格に関してプログラミング言語には二つの派閥があります:

Julia はある意味では「自動的な変換を行わない」方の派閥に属します: 数学演算子は特殊な構文を持った関数に過ぎず、引数が自動的に変換されることはありません。しかし、異なる型を持つ引数に対する数学演算の適用が多相多重ディスパッチの高度な応用の一つに過ぎないことは理解できる思います ──これは Julia の多重ディスパッチと型システムが特に得意とする処理です。

数学演算子におけるオペランドの自動的な昇格は Julia の機能を使っているだけです: 数学演算子には "全てを捕まえる" ディスパッチ規則が定義されており、これがオペランドの型の組み合わせに対する特殊な実装が存在しないときに呼び出されます。この "全てを捕まえる" メソッドはまず全てのオペランドを (ユーザーからも定義できる) 昇格規則を使って共通の型へと昇格させ、昇格し同じ型となった値に対して同じ演算子の特殊な実装を呼び出します。ユーザーが定義した型を昇格システムに組み込むのは容易であり、他の型との双方向の変換を行うメソッドを定義し、他の型のオペランドと一緒にされたときの昇格規則をいくつか定義すればそれで済みます。

変換

T 型の値を手に入れたいなら T(x) としてその型のコンストラクタを呼び出すのが標準的な方法です。しかしプログラマーが明示的に指示していなくても、特定の型の値から別の型の値への変換を自動的に挿入した方がプログラムが書きやすくなる状況が存在します。例えば配列への代入です: AVector{Float64} 型のとき、式 A[1] = 22Int から Float64 へと自動的に変換し、その値を A[1] に格納するべきです。これは convert 関数を使って実現されます。

convert 関数は型オブジェクトと変換する値という二つの引数を受け取り、第二引数を第一引数の型のインスタンスに変換した値を返します。例を見るのが分かりやすいでしょう:

julia> x = 12
12

julia> typeof(x)
Int64

julia> convert(UInt8, x)
0x0c

julia> typeof(ans)
UInt8

julia> convert(AbstractFloat, x)
12.0

julia> typeof(ans)
Float64

julia> a = Any[1 2 3; 4 5 6]
2×3 Array{Any,2}:
 1  2  3
 4  5  6

julia> convert(Array{Float64}, a)
2×3 Array{Float64,2}:
 1.0  2.0  3.0
 4.0  5.0  6.0

変換ができない場合もありますが、そのとき convertMethodError が発生させ、 要求された変換を行う方法を知らないことをプログラマーに伝えます:

julia> convert(AbstractFloat, "foo")
ERROR: MethodError: Cannot `convert` an object of type String to an object of type AbstractFloat
[...]

一部の言語は文字列を数値としてパースする処理や数値を文字列にフォーマットする処理を変換とみなします (多くの動的言語ではこの変換さえ自動的に行います) が、Julia はこの処理を変換とはみなしません。文字列の中には数値としてパースできるものもありますが、大部分の文字列は数値の表現として無効であり、正当なのはごく一部です。そのため Julia では専用の parse 関数が用意され、この操作をより明示的に行わなければいけないようになっています。

変換はいつ呼ばれるか?

言語に含まれる次の構文は convert を呼び出します:

変換 vs 構築

convert(T, x)T(x) が同じに見えるかもしれません。実を言うと、多くの場合で二つは等価です。ただ、重要な違いが一つあります: convert は暗黙の内に呼ばれる可能性があるので、そのメソッドが行えるのは "安全" で "サプライズの無い" 変換だけです。convert は同じ種類のものを表現する型 (例えば数値の異なる表現や文字列の異なるエンコーディングを使った表現) の間の変換を提供し、通常は可逆です。ある値を別の型に変換してから元の型に戻したなら、同じ値が得られるべきです。

コンストラクタと convert には四つの大きな違いがあります:

引数と異なる型の構築

コンストラクタは "変換" でない処理も実装できます。例えば Timer(2) として 2 秒のタイマーを作るとき、整数をタイマーに変換しているわけではありません。

可変コレクションの構築

x が最初から T 型のとき、convert(T, x)x をそのまま返します。これに対して、可変コレクション型 T に対する T(x) は必ず新しいコレクションを作成し、x の要素をそこにコピーします。

ラッパー型の構築

他の値を "ラップ" する型のコンストラクタは、引数の型が最初からラップされていたとしてもさらにラップを行う可能性があります。例えば Some(x)x をラップして値が存在することを示します (値が Some または nothing である状況を想定しています)。xSome(y) というオブジェクトだと、Some(x)y を二重にラップした値 Some(Some(y)) を返します。これに対して convert(Some, x) では x が最初から Some なので、x をそのまま返します。

自分の型のインスタンスを返さないコンストラクタ

非常に稀なケースとして、コンストラクタ T(x)T 型でないオブジェクトを返すのが理にかなう状況が存在します。ラッパー型を二度挟むと元に戻る (Flip(Flip(x)) == x) 場合や、大きな変更があったライブラリが後方互換性のために古い構文をサポートする必要がある場合などです。これに対して convert(T, x) はどんなときでも T 型の値を返すべきとされます。

新しい変換の定義

新しい型を定義するときは、その型の値を作成する方法はコンストラクタとしてのみ定義されるべきです。後になって暗黙の型変換の有用性が明らかになり、かつコンストラクタが上述の意味で "安全" であることが確認できたなら、convert メソッドを追加して構いません。新しい型に対する convert メソッドは通常適切なコンストラクタを呼び出すだけなので、とても簡単です。次のような定義となるでしょう:

convert(::Type{MyType}, x) = MyType(x)

このメソッドの第一引数は Type{MyType}であり、この型のインスタンスは MyType だけです。そのためこのメソッドは第一引数が MyType のときにだけ呼び出されます。第一引数には今までに説明していない構文が使われていることに注意してください: :: の前にあるべき変数の名前が省略されていて、型だけが与えられています。これは型だけが指定され参照が必要ない引数で利用できる構文です。今の例では第一引数の型がシングルトン型なので、参照せずともこの値は分かっています。

一部の抽象型のインスタンスはデフォルトで "十分似ている" とみなされ、Julia Base が convert のメソッドを提供します。例えば次の定義は、任意の二つの Number 型の値は一引数のコンストラクタで互いに変換できることを宣言しています:

convert(::Type{T}, x::Number) where {T<:Number} = T(x)

これは新しい Number 型に対してはコンストラクタだけを定義すればよいことを意味します。この convert の定義は新しい型に対する convert も処理できるためです。引数の型と変換先の型が同じである場合の等価変換も提供されます:

convert(::Type{T}, x::T) where {T<:Number} = x

同様の定義は AbstractString, AbstractArray, AbstractDict に対しても存在します。

昇格

昇格 (promotion) とは型の異なる複数の値を共通の型に変換する処理のことです。厳密には必要とされる条件ではないものの、通常は値の変換先となる共通の型は元の値を完全に表現できるものとされます。このとき値は "大きな" 型 ──変換する前の値を全て表せる型── へと変換されるので、「昇格 (promotion)」という単語が適しています。なおここで、オブジェクト指向における (構造的な) 上位型関係や Julia における抽象型の上位型関係は昇格と関係ないことに注意してください。昇格は型階層と関係を持たず、値を異なる表現へと変換するだけです。例えば Int32 の値は全て Float64 として表現できますが、Int32Float64 の部分型ではありません。

Julia において共通の大きな型への昇格は promote 関数を使って行われます。この関数は任意の個数の引数を受け取り、それらを共通の型へと変換した値が入った同じ長さのタプルを返します (変換が不可能なら例外が発生します)。promote は数値型を共通の型へ変換する処理に最もよく使われます:

julia> promote(1, 2.5)
(1.0, 2.5)

julia> promote(1, 2.5, 3)
(1.0, 2.5, 3.0)

julia> promote(2, 3//4)
(2//1, 3//4)

julia> promote(1, 2.5, 3, 3//4)
(1.0, 2.5, 3.0, 0.75)

julia> promote(1.5, im)
(1.5 + 0.0im, 0.0 + 1.0im)

julia> promote(1 + 2im, 3//4)
(1//1 + 2//1*im, 3//4 + 0//1*im)

浮動小数点数は引数に含まれる最もビット幅の大きな浮動小数点数型へ昇格されます。整数も同様に引数に含まれるビット幅の一番大きい型へ昇格されますが、同じビット幅の符号付き整数と符号無し整数は符号付き整数型へ昇格されます1。整数と浮動小数点数は浮動小数点数型へ昇格され、整数と有理数は有理数へ昇格され、有理数と浮動小数点数は浮動小数点数へ昇格されます。複素数と実数が混じる場合には適切な複素数へ昇格されます。

昇格の基本的な利用法については本当にこれで全てで、後はこれを "賢く" 応用するだけです。昇格の応用の中で最も分かりやすいのは算術演算子 +, -, *, / をはじめとした数値演算に対する "全てを捕まえる" メソッドの定義でしょう。promotion.jl にあるメソッドの定義の一部を示します:

+(x::Number, y::Number) = +(promote(x,y)...)
-(x::Number, y::Number) = -(promote(x,y)...)
*(x::Number, y::Number) = *(promote(x,y)...)
/(x::Number, y::Number) = /(promote(x,y)...)

これらのメソッド定義は、加算・減算・乗算・除算においてより特定的な規則が存在しないときは、引数を共通の型に昇格してやり直すように伝えています。そして、これで全てです: 算術演算における共通の数値型への昇格についてこれ以外には何も気にする必要はなく、後は自動的に行われます。promotion.jl にはこれ以外の算術関数や数学関数に対する "全てを捕まえる" 昇格メソッドがいくつも定義されていますが、このファイルを除くと Julia Base で promote が必要になっている箇所はほとんどありません。promote が最も使われるのは外部コンストラクタメソッドであり、コンストラクタの呼び出しに渡された異なる型の引数を適切な共通の型に昇格して内部コンストラクタメソッドに処理を委譲するために使われます。例えば rational.jl には次の外部コンストラクタメソッドが定義されています:

Rational(n::Integer, d::Integer) = Rational(promote(n,d)...)

この定義により、次の振る舞いが可能になります:

julia> Rational(Int8(15),Int32(-5))
-3//1

julia> typeof(ans)
Rational{Int32}

基本的にユーザー定義の型では、型パラメータをコンストラクタ関数でプログラマーに明示させるのが良い習慣です。ただときには、特に数値的な問題に対しては、昇格を自動的に行うようにしておくと便利でしょう。

昇格規則の定義

promote 関数にメソッドを直接定義することも原理的には可能ですが、そうすると考え得る全ての引数の型の組み合わせに対してたくさんの冗長な定義が必要になります。promote は補助関数 promote_rule を使って定義されているので、promote の代わりに promote_rule にメソッドを提供することが可能です。promote_rule は型オブジェクトの組を受け取り、その型のインスタンスを promote が受け取ったときの返り値の型を返します。例えば

promote_rule(::Type{Float64}, ::Type{Float32}) = Float64

を定義すると、64 ビット浮動小数点数と 32 ビット浮動小数点数の昇格先が 64 ビット浮動小数点数であることが宣言されます。昇格先の型が引数の型と一致する必要はありません。例えば、Julia Base には次の昇格規則が含まれます:

promote_rule(::Type{BigInt}, ::Type{Float64}) = BigFloat
promote_rule(::Type{BigInt}, ::Type{Int8}) = BigInt

二行目の昇格規則では任意精度の整数が絡む算術の結果を格納できる型が BigInt だけなので、返り値が BitInt となっています。また promote_rule(::Type{A}, ::Type{B}) があるなら、その逆 promote_rule(::Type{B}, ::Type{A}) を定義する必要はありません ──昇格処理は promote_rule を使うときに対称性を考慮します。

promote_rule 関数は promote_type という関数で基礎単位として利用されます。promote_type は任意の個数の型オブジェクトを受け取って、それらの共通の型 (promote が返す値の型) を返します。そのため実際の値が存在しない場合でも、promote_type を使えば昇格後の型を取得できます:

julia> promote_type(Int8, Int64)
Int64

promote は内部で promote_type を使って引数の値が変換されるべき型を計算しますが、ときには promote_type 単体が有用になることもあります。もし興味があるなら、昇格メカニズム全体が 35 行程度で定義されている様子を promotion.jl で確認できます。

ケーススタディ: 有理数の昇格

最後に、先ほどの Julia の有理数型のケーススタディをさらに進めます。有理数型の昇格規則では昇格メカニズムの比較的高度な使い方がされています:

promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:Integer} =
    Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{Rational{S}}) where {T<:Integer,S<:Integer} =
    Rational{promote_type(T,S)}
promote_rule(::Type{Rational{T}}, ::Type{S}) where {T<:Integer,S<:AbstractFloat} =
    promote_type(T,S)

最初の規則では、有理数と整数が昇格される型は有理数であり、その分母と分子の型は元の有理数の分母と分子の型と元の整数の型を昇格させたものであることが述べられています。二つ目の規則は同じことを二つの異なる有理数型に対して述べており、最終的な型は分母と分子の型を昇格させた有理数型です。三つ目の規則は有理数と浮動小数点数の昇格規則であり、分母と分子の型と浮動小数点数型を昇格させた型が返ります。

このたった三つの昇格規則が有理数型のコンストラクタおよび数値に対するデフォルトの convert メソッドと組み合わさることで、Julia が持つ全ての数値型 (整数・浮動小数点数・複素数) と有理数型の間の自然な相互変換が可能になります。ユーザー定義の数値型であっても、同様の方法で適切な convert メソッドと昇格規則を提供すれば、Julia 組み込みの数値型と同程度に自然な相互変換ができるようになります。


  1. 訳注: 英語版には整数型の昇格にマシンのネイティブワードサイズが関係するとあるが、これは現在の動作と異なるので修正した。以前は整数型の昇格でマシンのネイティブワードサイズが考慮されていたが、バージョン 0.7 からは考慮されないようになった (参照: #9292, 5af2021)。[return]

広告