独自の添え字を持った配列

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 始まりの添え字を使っていることを確認したい配列オブジェクトです。

既存コードの一般化

行うべきステップをまとめます:

各ステップについて説明します。

なぜ気を付けるべきか

非慣習的な添え字は「配列の添え字は必ず 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 から始まることを暗黙に仮定しています。そのため destsrc が異なる値から始まる添え字を持っていると、このコードは 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 始まりであることを示します。

境界検査では、専用の関数 checkboundscheckindex で判定が簡略化できる場合があります。

線形添え字 (LinearIndices)

A が多次元配列であったとしても A[i] のような単一の線形添え字を使った方が書きやすい (または効率が高い) アルゴリズムも存在します。配列の “ネイティブな” 添え字に関わらず、線形添え字は必ず 1:length(A) の範囲にあります。しかし線形添え字は一次元の配列 (別名 AbstractVector) で曖昧になります: v[i] は配列が持つ “ネイティブな” 添え字を使った格子添え字と線形添え字のどちらを表すのでしょうか?

この理由により、eachindex(A) を使って配列を反復するか、連続する整数で表された添え字が必要なときは LinearIndices(A) で取得することが推奨されます。LinearIndices(A)AAbstractVector なら axes(A, 1) を返し、そうでなければ 1:length(A) に似た形をした添え字を返します。

この定義から、一次元配列は必ず配列の “ネイティブな” 添え字を使った格子添え字アクセスを行うことが分かります。この事実をユーザーに知らせるために、非慣習的な添え字を持つ一次元配列 (形状が OneTo のタプルではなく Tuple{UnitRange} となる配列) を添え字変換関数に渡すとエラーが発生します。そういった関数は慣習的な添え字を持つ配列に対しては通常通りに動作します。

axesLinearIndices を使って書き直した 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 などとします。shapeInteger または 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.ZeroRangesimilarModuleA.ZeroArray を作るべきであることを示し、ModuleB.ZeroRangeModuleB.ZeroArray を作るべきであることを示すためです。この設計により異なる独自の配列型が平和に共存できるようになります。

Julia パッケージ CustomUnitRanges.jl を使えば ZeroRange を自分で書かずに済む場合もあります。

axes の特殊化

独自の AbstractUnitRange 型を作ったら、axes の特殊化でそれを使います:

Base.axes(A::ZeroArray) = map(n->ZeroRange(n), A.size)

ここでは ZeroArraysize というフィールドが存在するとしています (実装方法は他にも考えられます)。

axes(A, d) のフォールバックの定義は次の通りです:

axes(A::AbstractArray{T,N}, d) where {T,N} = d <= N ? axes(A)[d] : OneTo(1)

これが望む定義でない場合もあるでしょう: d > ndims(A) のとき OneTo(1) でない値を返すように特殊化が必要になるかもしれません。

同様に Baseaxes1 という関数を持ち、これは 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 ではなく) 分かりやすいエラーを出すようになります。

エラーの捕捉

自分で書いた新しい配列型が別のコードでエラーを起こしたときのデバッグでは、getindexsetindex! の実装に含まれる @boundscheck をコメントアウトするというテクニックが有用です。すると全ての要素アクセスが境界検査を行うようになります。他には --check-bounds=yes を付けて julia を再起動するという手もあります。

新しい配列型に対する sizelength を一時的に無効化するというテクニックも役立つ場合があります。正しくない仮定を置いているコードがこれらの関数を使っていることがよくあるためです。


日本語 Julia 書籍 (Amazon アソシエイト)
1 から始める Julia プログラミング
Julia プログラミングクックブック―言語仕様からデータ分析、機械学習、数値計算まで
スタンフォード ベクトル・行列からはじめる最適化数学