モジュール

Julia のモジュールは他の部分と分離された自由に使えるワークスペースであり、新しいグローバルスコープを作成します。モジュールは module Name ... end を使って構文的に区切られます。モジュールでは他の人が書いたコードとの名前の衝突を気にすることなくトップレベルの定義 (グローバル変数) を利用できます。モジュールでは他のモジュールに含まれる名前のどれを見えるようにする (インポートする) か、自身に含まれる名前のどれをパブリックにする (エクスポートする) かを指定できます。

モジュールの主な機能を次の例に示します。機能を示すためのコードであり、実行はできません:

module MyModule
using Lib

using BigLib: thing1, thing2

import Base.show

export MyType, foo

struct MyType
    x
end

bar(x) = 2x
foo(a::MyType) = bar(a.x) + 1

show(io::IO, a::MyType) = print(io, "MyType $(a.x)")
end

モジュールはファイル全体に渡ることが多いので、モジュールの本体はインデントしないのが標準のスタイルとなっています。

この MyModule というモジュールは型 MyType と二つの関数を定義します。関数 foo と型 MyType はエクスポートされるので、他のモジュールからインポートが可能です。一方で関数 barMyModule にプライベートとなります。

二行目の文 using Lib により、Lib という名前のモジュールが必要に応じて名前解決で利用可能になります。グローバル変数が現在のモジュールで定義を持たないと、システムは Lib がエクスポートする変数を探索し、見つかればそれをインポートします。これは現在のモジュールにおけるそのグローバル変数の利用が全て Lib で定義された同じ名前の変数として解決されることを意味します。

四行目の文 using BigLib: thing1, thing2 はモジュール BigLib から識別子 thing1thing2 だけを現在のモジュールに取り入れます。この名前が関数を指す場合には、その関数にメソッドを追加することはできません (つまり "使う" ことはできますが、拡張は許されません)。

import キーワードは using と同じ構文をサポートします。ただし using と異なり、import を使っても探索を行うモジュールは増えません。またインポートした関数にメソッドを追加できるのは import を使ったときだけという違いもあります。

上の MyModule では標準の show 関数にメソッドを追加しているので、import Base.show を使う必要があります。using で見えるようになった関数は拡張できません。

using または import によって変数がモジュールから見えるようになると、そのモジュールでは同じ名前の変数を作成できなくなります。またインポートした変数は読み込み専用です: グローバル変数への代入は現在のモジュールが持つ変数に対してだけ実行でき、代入先の変数を持つのが現在のモジュールでなければエラーが発生します。

モジュールの使い方のまとめ

モジュールを読み込むためのキーワードは usingimport の二つです。この違いを理解するために、次の例を考えます:

module MyModule

export x, y

x() = "x"
y() = "y"
p() = "p"

end

このモジュールは関数 xy を (export キーワードで) エクスポートし、関数 p をエクスポートしません。このモジュールとそこに含まれる関数を他のワークスペースから読み込むための方法を次の表にまとめます:

インポートコマンド スコープに入る名前 メソッドを拡張できる
関数
using MyModule export された全ての名前 (xy) MyModule.x, MyModule.y, MyModule.p MyModule.x, MyModule.y, MyModule.p
using MyModule: x, p x, p (存在しない)
import MyModule MyModule.x, MyModule.y, MyModule.p MyModule.x, MyModule.y, MyModule.p
import MyModule.x, MyModule.p x, p x, p
import MyModule: x, p x, p x, p

モジュールとファイル

基本的にファイルとファイル名はモジュールと関係がありません。モジュールはモジュール式にだけ関連付きます。一つのモジュールを複数のファイルに書くことも、一つのファイルに複数のモジュールを書くこともできます。複数のファイルに書かれたモジュールを一つのモジュール式に読み込む例を示します:

module Foo

include("file1.jl")
include("file2.jl")

end

同じコードを異なるモジュールにインクルードすれば、ミックスインのような効果を得られます。この手法を使うと同じコードを異なる定義で実行でき、例えば特定の演算子を安全なバージョンに切り替えてコードをテストするといった使い方が可能です:

module Normal
include("mycode.jl")
end

module Testing
include("safe_operators.jl")
include("mycode.jl")
end

標準モジュール

重要な標準モジュールが三つあります:

デフォルトのトップレベル定義と baremodule

全てのモジュールには using Base という文に加えて evalinclude という関数の定義が自動的に含まれます。この二つの関数はそれぞれ式とファイルをモジュールのグローバルスコープで評価します。

これらのデフォルトの定義が必要でないなら、モジュールを baremodule キーワードで定義してください (注意: こうしたとしても Core はインポートされます)。baremodule を使って通常の module を使ったときと同じコードを書くと次のようになります:

baremodule Mod

using Base

eval(x) = Core.eval(Mod, x)
include(p) = Base.include(Mod, p)

...

end

モジュールの相対パスと絶対パス

using Foo という文が与えられると、システムはトップレベルモジュールを収めた内部テーブルから Foo という名前のモジュールを検索します。もしそこに Foo が存在しなければ、システムは require(:Foo) を呼び出します。これにより多くの場合インストールされたパッケージからのコードの読み込みが起こります。

しかし中には自身の内部にモジュールを持つモジュールもあり、そのような場合にはトップレベルでないモジュールへのアクセスも必要になります。非トップレベルのモジュールへのアクセス方法は二つあります。一つは using Base.Sort のような絶対パスを使うもので、二つ目は次の例のように相対パスを使うものです。相対パスを使うと現在のモジュール (またはその親) が持つサブモジュールへのアクセスが簡単に行えます:

module Parent

module Utils
...
end

using .Utils

...
end

ここでは Parent モジュールがサブモジュール Utils を持ち、Paren のコードから Utils をインポートしようとしています。using の後に続くパスをドットで始めれば相対パスでモジュールをインポートできます。ドットを続けて書くとモジュール階層を上に進み、例えば using ..Utils と書けば Parent を含むモジュールから Utils が検索されます。

相対インポートは using 文と import 文でのみ正当なことに注意してください。

名前空間について

Base.sin のようにモジュール名で限定した名前を使うと、エクスポートされていない名前にもアクセスできます。これはデバッグで役立ちます。また他のモジュールの関数にメソッドを追加するときは関数名を限定する必要があります。ただし構文的な曖昧性を避けるために、名前が記号だけからなる関数 (例えば演算子) にメソッドを追加するときは、Base.+Base.:+ のようにする必要があります。演算子が二文字以上のときはさらに括弧が必要で、例えば Base.:(==) とします。

マクロのインポートまたはエクスポートでは名前の前に @ を付けます。例えば import Mod.@mac です。他のモジュールのマクロを呼び出すには Mod.@mac または @Mod.mac のようにします。

M.x = y という構文を使っても他のモジュールに含まれるグローバル変数への代入は行えません。グローバルな代入は必ず現在のモジュールにだけ影響を及ぼします。

global x としてグローバル変数を宣言すると、値を代入することなく変数の名前を "予約" できます。これを使うとモジュールの読み込みの後に初期化されるグローバル変数に対する名前の衝突を回避できます。

モジュールの初期化と事前コンパイル

大きなモジュールは読み込みに数秒単位の時間がかかります。これはモジュールに含まれる全ての文を実行するために大量のコードのコンパイルが必要となるためです。Julia はモジュールを事前コンパイルした結果のキャッシュを作成することでこの時間を削減します。

import または using でモジュールを読み込むと、漸進的に事前コンパイルされたモジュールファイルが自動で作成されます。そのためモジュールのコンパイルは初めてインポートしたときに自動で始まります。また Base.compilecache(modulename) を使えば手動で自動コンパイルを起動できます。作成されるキャッシュファイルは DEPOT_PATH[1]/compiled/ に保存されます。モジュールの依存先が変更されると、次に using あるいは import したときに再コンパイルが自動で行われます。ここでモジュールの依存先とは、Julia のビルド・インポートするモジュール・インクルードするファイル・モジュールのファイルで include_dependency(path) を使って明示的に宣言された依存ファイルといった情報のことです。

依存ファイルに対する変更の有無は include で読み込まれたファイルまたは include_dependency を使って明示的に追加されたファイルの変更時刻 (mtime) で判断されます。正確に言うと更新時刻を秒で切り捨てた時刻が使われます (更新時刻が秒未満の精度を持たないシステムをあるためです)。require の探索処理が見つけたファイルのパスが事前コンパイルされたファイルのパスと一致するかどうかも確認されます。また現在のプロセスに読み込まれた依存先も考慮し、実行中のシステムと事前コンパイルされたキャッシュが非互換性にならないように、既に読み込まれた依存ファイルが変更あるいは削除されたときはモジュールの再コンパイルを行いません。

あるモジュールを事前コンパイルしてはいけないと分かっている場合には、そのモジュール (の通常は先頭部分) に __precompile__(false) を追加してください。こうすると Base.compilecache でエラーが発生するようになります。そのため usingimport はこのモジュールに対する事前コンパイルとキャッシュ作成をスキップし、現在のプロセスにモジュールを直接読み込みます。さらにそのモジュールは他の事前コンパイルされたモジュールからインポートできなくなります。

モジュールを書くときは、漸進的にリンクされる共有ライブラリに特有の挙動に注意が必要です。例えば外部の状態はキャッシュに保存されないので、実行時の初期化とコンパイル時の初期化の区別を意識する必要があります。この区別を分かりやすくするために、Julia では実行時に実行する初期化処理を __init__() 関数に書くことになっています。__init__ 関数はコンパイル時 (--output-*) には実行されません。この関数は事実上コードが読み込まれるときに一度だけ実行されるものと仮定して構いません。もちろん必要なら事前コンパイル中にこの関数を呼び出しても構いませんが、この関数はコンパイルイメージに格納する必要のない ──あるいは格納するべきではない── ローカルマシンの状態を使った処理を行うものとされます。__init__ 関数はモジュールがプロセスに読み込まれた後に呼び出されます。漸進的コンパイル (--output-incremental=yes) で読み込まれるときは実行されますが、フルコンパイルで読み込まれるときは実行されません。

具体的に言うと、あるモジュールで function __init__() を定義したとき、Julia はそのモジュールを (import, using, require などで) 初めて読み込んだ直後に __init__() を実行します。言い換えると、__init__ はモジュールに含まれる全ての文を実行した後に一度だけ実行されます。モジュールのインポートが完了した後に呼ばれるので、サブモジュールやインポートしたモジュールの __init__ は親のモジュールの __init__前に呼ばれます。

よくある __init__ の利用例としては、外部 C ライブラリの関数の初期化や、外部ライブラリが返すポインタが関係するグローバル変数の初期化があります。例として C ライブラリ libfoo を使おうとしている状況を考えます。実行時にこのライブラリの初期化関数 foo_init() を呼び出す必要があり、さらに void *foo_data() という関数が返すポインタをグローバル変数 foo_data_ptr に格納したいとします ──ポインタアドレスは実行ごとに変わるので、この変数は (コンパイル時ではなく) 実行時に初期化が必要です。この初期化処理は、次の __init__ を定義することで実現できます:

const foo_data_ptr = Ref{Ptr{Cvoid}}(0)
function __init__()
    ccall((:foo_init, :libfoo), Cvoid, ())
    foo_data_ptr[] = ccall((:foo_data, :libfoo), Ptr{Cvoid}, ())
    nothing
end

本筋とは関係ありませんが、__init__ のような関数の中でグローバル変数を定義しても問題はありません: これは動的言語を使う利点の一つです。ただグローバルスコープで定数を作っておけば、コンパイラに型を伝えることでより最適化されたコードの生成が可能になります。それから当然、foo_data_ptr を利用する他のグローバル変数も __init__ の中で初期化される必要があります。

ccall を使わずに計算できる Julia オブジェクトを指す定数の計算は基本的に __init__ で行わなくて構いません。定数の定義を事前コンパイルして、キャッシュされたモジュールイメージからロードできます。事前コンパイルできるオブジェクトには配列のようなヒープにアロケートされる複雑なオブジェクトも含まれます。ただし事前コンパイルした結果を正しく動作させるには、生のポインタ値を返すルーチンだけは実行時に呼び出す必要があります (キャッシュされるとき Ptr オブジェクトは isbits オブジェクトとして隠されていない限りヌルポインタに変換されます)。Julia 関数 cfunctionpointer も同様です。

辞書型や集合型、そして一般には hash(key) の出力を利用する任意の型の値では、事前コンパイルで厄介な問題が発生します。多くの場合キーは数値・文字列・シンボル・区間・Expr もしくはこれらの複合型 (配列・タプル・集合・ペアなど) であり、この場合には事前コンパイルに問題はありません。問題が起こるのはキーが FunctionDataType といった特定の型のとき、およびキーが hash メソッドを定義していないユーザー定義型で、フォールバックの hash メソッドが (objectid を通じて) オブジェクトのメモリアドレスを参照しているためにハッシュが実行ごとに変わるときです。キーがこういった型を含む場合、あるいは含まないと確信できない場合には、念のため辞書の初期化を __init__ 関数で行うようにしてください。あるいはコンパイル時に安全に初期化できるよう事前コンパイル処理で特別に扱われる辞書型 IdDict を使うこともできます。

事前コンパイルを使うときは、コンパイルフェーズと実行フェーズの区別をしっかり意識することが重要です。このモードでは 「julia は任意の Julia コードを実行できるコンパイラであって、コンパイルされたコードを生成できるスタンドアローンのインタープリタではない」という事実が明確になるでしょう。

事前コンパイルでありがちなミスをさらにいくつか示します:

  1. グローバルなカウンター (例えばオブジェクトをユニークに特定するための識別子) を使いたいとします。次のスニペットを考えてください:

    mutable struct UniquedById
        myid::Int
        let counter = 0
            UniquedById() = new(counter += 1)
        end
    end
    

    このコードは全てのインスタンスにユニークな ID を与えることを意図していますが、カウンターの値はコンパイルの最後に記録されます。そのため漸進的にコンパイルされたこのモジュールを使う全てのモジュールはカウンターを同じ値から使い始めることになります。

    objectid (メモリポインタのハッシュとして計算される値) にも同様の問題があることに注意してください (下記の Dict に関する注意事項を参照)。

    対処法の一つとして、@__MODULE__ をキャプチャするマクロを使って現在の counter の値だけでなくモジュールの名前も保存するという方法があります。ただ、グローバルな状態に依存しないようコードを再設計した方がよいかもしれません。

  2. DictSet といった連想コレクションは __init__ で再ハッシュが必要です (もしかすると将来、初期化関数を登録する仕組みが追加されるかもしれません)。

  3. 読み込みまで影響が残る副作用のある処理をコンパイル時に行わないでください。例えば、他の Julia モジュールに含まれる配列や変数を改変したり、ファイルやデバイスのハンドルを管理したり、メモリなどのシステムリソースへのポインタを保存したりしないでください。

  4. 他のモジュールのグローバルな状態のコピーを作らないでください。コピーは探索パスを使わずに直接変数を参照すると発生します。例えば、グローバルスコープにおける次のコードを考えます:

    # これは正しくない。Base.stdout をこのモジュールにコピーしてしまう。
    # mystdout = Base.stdout
    # アクセッサ関数を使うのが正しい:
    getstdout() = Base.stdout # 最良の方法
    # 代入を実行時に行ってもよい:
    __init__() = global mystdout = Base.stdout # これでもよい
    

間違った処理を行わないように、事前コンパイル中に行える演算には追加の制限があります:

  1. 他のモジュールの eval を呼び出して副作用を起こすことはできません。漸進的事前コンパイルフラグが設定されているときは警告が発生します。
  2. __init__() が開始された後にローカルスコープで global const 文を使うことはできません。
  3. 漸進的事前コンパイルを行っているときは、モジュールの置き換えが実行時エラーとなります。

さらに注意すべき点を示します:

  1. ソースファイルを変更しただけではコードの再読み込みやキャッシュの無効化は行われません (Pkg.update でも同様です)。また Pkg.rm の後にクリーンアップは行われません。
  2. 形を変更した配列がメモリを共有する機能は事前コンパイル中は無効化されます (それぞれのビューが別々のコピーを受け取ります)。
  3. コンパイル時と実行時でファイルシステムが変更されないと仮定するのは間違っています。つまり @__FILE__source_path() あるいは BinDeps.jl@checked_libを使って実行時にリソースを探索するのは避けるべきです。これが避けられない場合もありますが、可能な限り、コンパイル時にリソースをモジュールへコピーして実行時にはコピーが必要ないようにしてください。
  4. シリアライザは WeakRef オブジェクトとファイナライザを正しく処理できません (将来のリリースで改善される予定です)。
  5. 内部メタデータオブジェクト (Method, MethodInstance, MethodTable, TypeMapLevel, TypeMapEntry やこれらのフィールド) のインスタンスに対する参照をキャプチャするのは基本的に避けるべきです。シリアライザがこういったオブジェクトを上手く処理できず、予期しない結果が得られる可能性があります。これを行ってもエラーにはなりませんが、システムがオブジェクトをコピーしたり、オブジェクトのユニークなインスタンスを作成したりする可能性があることを認識しておいてください。

モジュールを開発するときは漸進的事前コンパイルをオフにすると便利な場合もあります。コマンドライン引数 --compiled-modules={yes|no} を使えばモジュールの事前コンパイルを制御できます。--compiled-modules=no として Julia を起動すると、モジュール (およびその依存先) の読み込みにおいてコンパイルキャッシュにあるシリアライズされたモジュールが無視されるようになります (Base.compilecache の呼び出しは可能です)。このコマンドライン引数は Pkg.build に渡され、パッケージのインストール・更新・明示的ビルドにおける自動的な事前コンパイルが有効化/無効化されます。

広告