独自の添え字を持った配列
Julia の配列の添え字は 1 で始める慣習となっていますが、世の中には添え字が 0 から始まる言語もあれば、最初の添え字を好きなように指定できる (Fortran のような) 言語もあります。標準を決める (Julia なら添え字を 1 で始めると定める) ことのメリットも存在しますが、1:size(A,d)
以外の (0:size(A,d)-1
でもない) 添え字が使えると非常に簡単に書けるアルゴリズムも存在します。こういったアルゴリズムを書きやすくするために、Julia は任意の添え字を持った配列をサポートします。
この章の目的は「自分のコードで独自の添え字を持った配列をサポートするにはどうすれば?」という疑問への答えを示すことです。まずは一番簡単な場合を片付けます: もし非慣習的な添え字を持つ配列を処理できなくても構わないことが分かっているなら、幸いにも答えは「何もしない」です。非慣習的な配列を使う古いコードであっても、Julia が公開するインターフェースを使ってさえいればコードを改造しなくてもそのまま動作します。もしユーザーから 1 始まりの慣習的な添え字を持つ配列だけを受け取りたい場合には、次の文を追加してください:
Base.require_one_based_indexing(arrays...)
ここで arrays...
は 1 始まりの添え字を使っていることを確認したい配列オブジェクトです。
既存コードの一般化
行うべきステップをまとめます:
size
をaxes
に置き換える。1:length(A)
をeachindex(A)
あるいはLinearIndices(A)
に置き換える。Array{Int}(undef, size(B))
の形をした明示的なアロケートをsimilar(Array{Int}, axes(B))
で置き換える。
各ステップについて説明します。
なぜ気を付けるべきか
非慣習的な添え字は「配列の添え字は必ず 1 から始まる」という多くの人々が常識的に抱く仮定を破るので、そのような配列を使うコードにはエラーが発生しかねない落とし穴が様々な場所に存在します。最も厄介なバグは不正確な結果や segfault (Julia 全体のクラッシュ) を引き起こすでしょう。例として次の関数を考えます:
function mycopy!(dest::AbstractVector, src::AbstractVector)
length(dest) == length(src) || throw(DimensionMismatch("vectors must match"))
# これで @inbouds を使っても大丈夫 (...?)
for i = 1:length(src)
@inbounds dest[i] = src[i]
end
dest
end
このコードは二つのベクトルの添え字が 1 から始まることを暗黙に仮定しています。そのため dest
と src
が異なる値から始まる添え字を持っていると、このコードは segfault を起こす可能性があります (segfault が起きたときは julia
に --check-bounds=yes
オプションを付けて起動すると発生場所が特定しやすくなります)。
境界検査とループ反復では axes
を使う
axes(A)
は A
の各次元における正当な添え字の範囲を示す AbstractUnitRange
オブジェクトのタプルを返します。A
が非慣習的な添え字を持つときは、この区間が 1 から始まらない可能性があります。特定の d
番目の次元に対する正当な添え字が必要なときは axes(A, d)
が利用できます。
Base は範囲を表す独自型 OneTo
を定義します。OneTo(n)
は 1:n
と同じですが、添え字の下限が 1 であることが (型システムを通して) 保証されていることを表します。新しい AbstractArray
型に対して axes
がデフォルトで返すのは OneTo
であり、このことが慣習的な添え字が 1 始まりであることを示します。
境界検査では、専用の関数 checkbounds
と checkindex
で判定が簡略化できる場合があります。
線形添え字 (LinearIndices
)
A
が多次元配列であったとしても A[i]
のような単一の線形添え字を使った方が書きやすい (または効率が高い) アルゴリズムも存在します。配列の "ネイティブな" 添え字に関わらず、線形添え字は必ず 1:length(A)
の範囲にあります。しかし線形添え字は一次元の配列 (別名 AbstractVector
) で曖昧になります: v[i]
は配列が持つ "ネイティブな" 添え字を使った格子添え字と線形添え字のどちらを表すのでしょうか?
この理由により、eachindex(A)
を使って配列を反復するか、連続する整数で表された添え字が必要なときは LinearIndices(A)
で取得することが推奨されます。LinearIndices(A)
は A
が AbstractVector
なら axes(A, 1)
を返し、そうでなければ 1:length(A)
に似た形をした添え字を返します。
この定義から、一次元配列は必ず配列の "ネイティブな" 添え字を使った格子添え字アクセスを行うことが分かります。この事実をユーザーに知らせるために、非慣習的な添え字を持つ一次元配列 (形状が OneTo
のタプルではなく Tuple{UnitRange}
となる配列) を添え字変換関数に渡すとエラーが発生します。そういった関数は慣習的な添え字を持つ配列に対しては通常通りに動作します。
axes
と LinearIndices
を使って書き直した mycopy!
を示します:
function mycopy!(dest::AbstractVector, src::AbstractVector)
axes(dest) == axes(src) || throw(DimensionMismatch("vectors must match"))
for i in LinearIndices(src)
@inbounds dest[i] = src[i]
end
dest
end
一般的な similar
を使って格納領域をアロケートする
配列の格納領域は Array{Int}(undef, dims)
や similar(A, args...)
でアロケートされることがよくあります。しかし添え字を他の配列と合わせる必要があるときは、これだけでは不十分です。similar(storagetype, shape)
を使うとこういったパターンを一般的に書けます。ここで storagetype
は内部で使われる "慣習的な" 振る舞いを示す型であり、Array{Int}
や BitArray
などとします。shape
は Integer
または AbstractUnitRange
からなるタプルであり、作成される配列の添え字を指定します。なお A
と同じ添え字を持つゼロで埋まった配列は zeros(A)
で作成できます。
具体的な例を示します。A
が慣習的な添え字を持つ配列なら、similar(Array{Int}, axes(A))
は最終的に Array{Int}(undef, size(A))
を呼び出して配列を返します。A
が 非慣習的な添え字を持つ AbstractArray
なら、similar(Array{Int}, axes(A))
は A
と同じ形状と添え字を持ち Array{Int}
のように "振る舞う" オブジェクトを返します (最も簡単な実装としては、Array{Int}(undef, size(A))
で配列をアロケートして添え字をずらす型にそれを渡すという方法が考えられます)。
similar(Array{Int}, (axes(A, 2),))
は A
の列と同じ添え字を持つ AbstractVector{Int}
(一次元配列) をアロケートすることにも注意してください。
1 始まりでない添え字を持つ独自配列を作成する
定義する必要のあるメソッドの大部分は標準の AbstractArray
型と同様です。この部分についてはマニュアルのAbstractArray
インターフェースの節を参照してください。以降では非慣習的な添え字を定義するために必要なことを説明します。
独自の AbstractUnitRange
型
添え字が 1 始まりでない配列型を書くときは axes
を特殊化して UnitRange
または独自の AbstractUnitRange
型を返すようにするはずですが、おそらくは後者の方が望ましい結果となります。独自型を使う利点は smilar
のような関数に対してアロケートの型を伝えられる点にあります。例えば添え字が 0
から始まる配列型を書くときは AbstractUnitRange
の部分型 ZeroRange
を新しく作り、ZeroRange(n)
を 0:n-1
として扱うところから始めるとよいでしょう。
一般的に言って、あなたが書いているパッケージからは ZeroRange
をエクスポートしない方が望ましいはずです。他のパッケージが独自に ZeroRange
を実装する可能性がありますが、異なる ZeroRange
型が存在することには (意外にも) 利点があります: ModuleA.ZeroRange
は similar
が ModuleA.ZeroArray
を作るべきであることを示し、ModuleB.ZeroRange
は ModuleB.ZeroArray
を作るべきであることを示すためです。この設計により異なる独自の配列型が平和に共存できるようになります。
Julia パッケージ CustomUnitRanges.jl を使えば ZeroRange
を自分で書かずに済む場合もあります。
axes
の特殊化
独自の AbstractUnitRange
型を作ったら、axes
の特殊化でそれを使います:
Base.axes(A::ZeroArray) = map(n->ZeroRange(n), A.size)
ここでは ZeroArray
に size
というフィールドが存在するとしています (実装方法は他にも考えられます)。
axes(A, d)
のフォールバックの定義は次の通りです:
axes(A::AbstractArray{T,N}, d) where {T,N} = d <= N ? axes(A)[d] : OneTo(1)
これが望む定義でない場合もあるでしょう: d > ndims(A)
のとき OneTo(1)
でない値を返すように特殊化が必要になるかもしれません。
同様に Base
は axes1
という関数を持ち、これは ndims(A) > 0
の実行時チェックを行わずに axes(A, 1)
を返します (最適化の用途で利用されます)。axes1
の定義を示します:
axes1(A::AbstractArray{T,0}) where {T} = OneTo(1)
axes1(A::AbstractArray) = axes(A)[1]
独自配列型で一つ目のゼロ次元配列に対する定義が問題なら、このメソッドも適切に特殊化してください。
similar
の特殊化
独自に ZeroRange
型を定義したなら、続いて similar
の特殊化を二つ追加するべきです:
function Base.similar(A::AbstractArray,
T::Type,
shape::Tuple{ZeroRange,Vararg{ZeroRange}})
# ...
end
function Base.similar(f::Union{Function,DataType},
shape::Tuple{ZeroRange,Vararg{ZeroRange}})
# ...
end
両方とも独自配列型のアロケートを行います。
reshape
の特殊化
省略しても構いませんが、次のメソッドを定義することもできます:
Base.reshape(A::AbstractArray, shape::Tuple{ZeroRange,Vararg{ZeroRange}}) = ...
こうすると独自の添え字を持った配列を reshape
から返せるようになります。
AbstractArray
の部分型でない配列風オブジェクトについて
has_offset_axes
は呼び出されたオブジェクトに対して axes
が定義されることを前提としています。何らかの理由で独自のオブジェクトに対して axes
メソッドが定義されないときは、次のメソッドを追加するべきです:
Base.has_offset_axes(obj::MyNon1IndexedArraylikeObject) = true
こうすると 1 始まりの添え字を使うコードが (正しくない結果や Julia の segfault ではなく) 分かりやすいエラーを出すようになります。
エラーの捕捉
自分で書いた新しい配列型が別のコードでエラーを起こしたときのデバッグでは、getindex
と setindex!
の実装に含まれる @boundscheck
をコメントアウトするというテクニックが有用です。すると全ての要素アクセスが境界検査を行うようになります。他には --check-bounds=yes
を付けて julia
を再起動するという手もあります。
新しい配列型に対する size
と length
を一時的に無効化するというテクニックも役立つ場合があります。正しくない仮定を置いているコードがこれらの関数を使っていることがよくあるためです。