変数のスコープ

ある変数のスコープ (scope) とは、その変数を参照できるコード領域のことです。変数のスコープがあると変数名の衝突を防ぐことができます。スコープの概念に難しいところはありません: 二つの関数が x という引数を持っていたとしても、その二つの x が同じものを指すことがないのはスコープのおかげです。同様に、異なるコードブロックに含まれる同名の変数が異なるものを指す場面は多く存在します。同名の変数が同じものを指す (あるいは指さない) のはいつかを決めるのがスコープ規則であり、この章ではスコープ規則を詳しく見ていきます。

特定の Julia の構文はスコープブロック (scope block) を作ります。スコープブロックは何らかの変数の集合に対するスコープとして使われるコード領域のことです。変数のスコープはソースコードの行番号で表されるのではなく、このスコープブロックで表されます。Julia は二種類のスコープブロックを持ちます: グローバルスコープ (global scope) とローカルスコープ (local scope) です。ローカルスコープはネストできます。さらに Julia のローカルスコープにはハードスコープ (hard scope) とソフトスコープ (soft scope) の区別があります。この区別は同じ名前のグローバル変数の隠蔽が許されるかどうかに影響します。

スコープ構文

スコープブロックを作成する構文は次の通りです:

構文 この構文が作成するスコープ この構文を使えるスコープ
module, baremodule グローバル グローバル
struct ローカル (ソフト) グローバル
for, while, try ローカル (ソフト) グローバルまたはローカル
macro ローカル (ハード) グローバル
let, 関数, 内包表記, ジェネレータ ローカル (ハード) グローバルまたはローカル

beginif がこの表に含まれていませんが、それはこれらの構文が新しいスコープを作成しないためです。三種類のスコープは微妙に異なる規則に従うので、以下ではその規則を説明します。

Julia は字句的スコープを使う言語です。これは関数のスコープが呼び出し側のスコープを受け継がず、関数が定義されたスコープを受け継ぐことを意味します。例えば次のコードにおける foo の定義で使われる x は、モジュール Bar が作るグローバルスコープにおける x を指します:

julia> module Bar
           x = 1
           foo() = x
       end;

foo が使われる場所のスコープにおける x ではありません:

julia> import .Bar

julia> x = -1;

julia> Bar.foo()
1

つまり字句的スコープの言語では、特定のコード片に含まれる変数が指す値はプログラムの実行に依存せず、変数の定義だけから決定します。

他のスコープの内部にあるスコープからは、外側のスコープを全て "見る" ことができます。一方、外側のスコープから内側のスコープが持つ変数を見ることはできません。

グローバルスコープ

モジュールは新しいグローバルスコープを作成するので、他のモジュールが持つグローバルスコープと分離されます。モジュールから他のモジュールの変数を参照するには using 文または importを使うか、ドット構文による限定アクセス (qualified access) を使います。つまりモジュールは名前と値を関連付けるファーストクラスのデータ構造であるだけではなく、いわゆる名前空間 (namespace) でもあります。

モジュールが持つ変数束縛は外部から参照できますが、値の改変は変数を持つモジュールからのみ行えます。ただし非常用のハッチとして、eval を使ってモジュール内部で任意のコードを評価して変数を変更することは常に可能です。ここから eval を呼ばないコードは他のモジュールの束縛を改変しないことが保証されます。

モジュールの使用例を示します:

julia> module A
           a = 1 # A のスコープにおけるグローバル変数
       end;

julia> module B
           module C
               c = 2
           end
           
           b = C.c    # 限定アクセスを使えば
                      # ネストされたグローバルスコープにアクセスできる
           import ..A # モジュール A を利用可能にする
           d = A.a
       end;

julia> module D
           b = a # D のグローバルスコープは A のものと異なるのでエラー
       end;
ERROR: UndefVarError: a not defined

julia> module E
           import ..A # モジュール A を利用可能にする
           A.a = 2    # 下記のエラーが発生する
       end;
ERROR: cannot assign variables in other modules

対話プロンプト (REPL) はモジュール Main のグローバルスコープで動作します。

ローカルスコープ

大部分のコードブロックは新しくローカルスコープを作成します (詳しくは上のを見てください)。一部の言語では新しい変数を使う前に明示的に宣言が必要ですが、明示的な宣言は Julia でも可能です: 任意のローカルスコープで local x とすれば、x という名前の変数が外のスコープにあるかどうかに関わらず、そのスコープにローカル変数 x が宣言されます。全てのローカル変数をこのように宣言するのは長くて面倒なので、Julia は他の多くの言語と同様、ローカルスコープで起こる新しい変数への代入を新しいローカル変数の暗黙な宣言とみなします。多くの場合でこれは非常に直感的な動作をしますが、"直感的な" 動作によくあるように、詳細は素朴な想像と微妙に異なります。

ローカルスコープに x = <value> という代入式が含まれると、Julia は代入式のある場所と x という変数が既に存在するかどうかに応じて、次の規則で式の意味を決定します:

  1. 既存のローカル変数: x が既にローカル変数として存在するなら、そのローカル変数 x に値が代入されます。
  2. ハードスコープ: x がローカル変数として存在せず、かつ代入がハードスコープを作る構文 (let ブロック・関数またはマクロの本体・内包表記・ジェネレータ) の内部で起きているなら、x という名前の新しいローカル変数が代入式と同じスコープに作成されます。
  3. ソフトスコープ: x がローカル変数として存在せず、かつ代入式を包む構文が作るローカルスコープが全てソフト (ループ・try/catch ブロック・struct ブロック) なら、動作はグローバル変数 x が定義されているかによって決まります:
    • もしグローバル変数 x が定義されていないなら、新しいローカル変数 x が代入式と同じスコープで定義されます。
    • もしグローバル変数 x が定義されているなら、代入式は曖昧とみなされ、さらに場合分けが起きます:
      • 対話的でない状況 (ファイルや eval の実行) では、曖昧性の警告と共にローカル変数が作成されます。
      • 対話的な状況 (REPL や Jupyter Notebook) では、グローバル変数 x への代入が起こります。

ここから対話的でない状況ではハードスコープとソフトスコープが等価な振る舞いをすることが分かります。ただしソフトスコープで暗黙の内に定義される (local x として宣言されていない) ローカル変数がグローバル変数を隠す場合には警告が出力されます。対話的な状況では、利便性のため複雑なヒューリスティックが使われます。以降の例で詳しく触れます。

規則が分かったので、いくつか例を見ていきます。これから示す例は全て新しい REPL セッションで実行されることを想定しています。つまり各スニペットが持つグローバル変数はそこで定義されたものだけです。

まずは一番簡単な、ハードスコープ内部における代入から始めましょう。次の例は関数の本体において、存在しない変数に対して代入を行っています:

julia> function greet()
           x = "hello" # 新しいローカル変数
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # グローバル変数
ERROR: UndefVarError: x not defined

greet 関数内部の代入 x = "hello" は、関数をスコープとする新しいローカル変数 x を作成します。この振る舞いには次の二つの事実が関係します: この代入が (関数定義によって作成される) ローカルスコープで起こっていることと、代入の前に x という名前のローカル変数が存在しないことです。

greet 関数で定義される x はローカル変数なので、グローバル変数 x の定義の有無には影響を受けません。greet を定義して呼び出す前に x = 123 を定義する例を次に示します:

julia> x = 123 # グローバル変数
123

julia> function greet()
           x = "hello" # 新しいローカル変数
           println(x)
       end
greet (generic function with 1 method)

julia> greet()
hello

julia> x # グローバル変数
123

greet 関数内部の x はローカル変数なので、その値はグローバル変数 x の値 (および x が存在するかどうか) に影響を受けません。関数定義が作成するハードスコープの規則は x という名前のグローバル変数が存在するかどうかを気にしないので、x に対する代入はローカルとなります (ただし x がグローバル変数と宣言された場合には別です)。

次に考えるのは、x というローカル変数が既に存在している状況です。この場合 x = <vlaue> は必ず既存のローカル変数への代入となります。次の sum_to 関数は 1 から n までの自然数の和を計算します:

function sum_to(n)
    s = 0 # 新しいローカル変数
    for i = 1:n
        s = s + i # 既存のローカル変数に対する代入
    end
    return s # 同じローカル変数
end

一つ前の例と同じように、sum_to の最初の行にある代入式は新しいローカル変数 s を作成します。for ループは新しくローカルスコープを作成しますが、これは関数のスコープに含まれます。そのため s = s + i が評価されるとき s はローカル変数として存在し、この代入式は新しく変数を作らずに既存の s を更新します。REPL で sum_to を呼び出せばこのことを確認できます:

julia> function sum_to(n)
           s = 0 # 新しいローカル変数
           for i = 1:n
               s = s + i # 既存のローカル変数に対する代入
           end
           return s # 同じローカル変数
       end
sum_to (generic function with 1 method)

julia> sum_to(10)
55

julia> s # グローバル変数
ERROR: UndefVarError: s not defined

ssum_to におけるローカル変数なので、この関数を呼び出してもグローバル変数 s は影響を受けません。また for ループ内の s = s + is = 0s を更新していることは、sum_to(10) が 1 から 10 までの自然数の和 55 を計算していることからも分かります。

for ループの本体が新しくスコープを作っていることを確認するために、上のコードを少し冗長にしてみましょう。次の関数 sum_to2 は、s を和 s + i で更新する前に変数 t へ一度保存します:

julia> function sum_to2(n)
           s = 0 # new local
           for i = 1:n
               t = s + i # 新しいローカル変数 t
               s = t # 既存のローカル変数 s に対する代入
           end
           return s, @isdefined(t)
       end
sum_to2 (generic function with 1 method)

julia> sum_to2(10)
(55, false)

このバージョンは和 s に加えて t という名前のローカル変数が関数の一番外側のローカルスコープで定義されているかを表す真偽値を返します。実行例から分かるように、tfor ループの本体より外側では定義されていません。ここでも理由はハードスコープの規則です: t に対する代入は関数、つまりハードスコープの内側で起こっているので、この代入は新しいローカル変数 t を現在のスコープ、つまりループ本体のスコープに作成します。t という名前のグローバル変数があったとしても、この関数の動作は変わりません ──ハードスコープの規則はグローバルスコープに一切影響を受けないからです。

次はソフトスコープの規則が絡むさらに微妙なケースを見ましょう。greet 関数と sum_to2 関数の本体をソフトスコープのコンテキストに移動させれば、ソフトスコープの挙動を確認できます。まず greet 関数の本体を for ループ ──ソフトスコープを作る構文── の中に移動させ、それを REPL で評価します:

julia> for i = 1:3
           x = "hello" # 新しいローカル変数
           println(x)
       end
hello
hello
hello

julia> x
ERROR: UndefVarError: x not defined

for ループが評価されるときグローバル変数 x は定義されていないので、ソフトスコープの最初の規則が適用され、x がローカル変数として定義されます。そのためループの実行が終われば x は未定義となります。

次は sum_to2 の本体をグローバルスコープに取り出して実行してみましょう。n10 に固定します:

s = 0
for i = 1:10
    t = s + i
    s = t
end
s
@isdefined(t)

このコードは何をするでしょうか? ヒント: ひっかけ問題です。

答えは「場合による」です。このコードを対話的に入力すれば、関数のときと同じ振る舞いとなります。しかしこのコードをファイルから読み込むと、曖昧であるという警告と未定義変数のエラーが発生します。まず REPL で何が起こるかを見ます:

julia> s = 0 # グローバル変数
0

julia> for i = 1:10
           t = s + i # 新しいローカル変数 t
           s = t # グローバル変数 s への代入
       end

julia> s # グローバル変数
55

julia> @isdefined(t) # グローバル変数
false

代入式がグローバル変数への代入なのか新しいローカル変数の作成なのか判断するとき、REPL は左辺の名前のグローバル変数が定義されているかどうかを確認します。その名前のグローバル変数が存在すれば代入式でそのグローバル変数が更新され、存在しなければ代入式で新しいローカル変数が作成されます。このコードでは二つのケースが両方表れています:

二つ目の事実が s が更新される理由であり、一つ目の事実が t がループの後で未定義となる理由です。

次はこのコードがファイルに書かれているとして評価してみます:

julia> code = """
       s = 0 # グローバル変数
       for i = 1:10
           t = s + i # 新しいローカル変数 t
           s = t # 新しいローカル変数 s (警告が出る)
       end
       s, # グローバル変数
       @isdefined(t) # グローバル変数
       """;

julia> include_string(Main, code)
 Warning: Assignment to `s` in soft scope is ambiguous because a global variable by the same name exists: `s` will be treated as a new local. Disambiguate by using `local s` to suppress this warning or `global s` to assign to the existing global variable.
 @ string:4
ERROR: LoadError: UndefVarError: s not defined

ここで使っている include_string は文字列をファイルの内容であるかのように実行する関数です。code をファイルに保存してからそのファイルを include しても同じ結果となります。実行例から分かるように、こうしたときの動作は REPL で同じコードを評価したときとは大きく異なります。何が起きているかを順に説明します:

この例にはスコープの重要な特徴が示されています: スコープにおいて一つの変数は一つの意味しか持つことができず、変数の意味は評価順序に関係なく決定されます。ループ中の s = t という式が s をループ中でだけ有効なローカル変数に定めるので、二回目の反復で t = s + i の右辺に表れる s は (ループの最初の行にあり最初に評価されるにもかかわらず) ローカルです。ループ一行目の s をグローバルにして二行目の s をローカルにすればよいと思うかもしれませんが、同じスコープに含まれる同じ変数は同じ意味を持たなければならないので、そうはできません。

ソフトスコープについて

これでローカルスコープの規則を全て説明できました。ただこの章を終える前に、曖昧なソフトスコープが対話的なときと対話的でないときで異なる振る舞いをする理由についてもう少し話しておくべきでしょう。明らかな疑問が二つあります:

  1. どんなときでも REPL のような動作をしてはなぜいけないのか?
  2. どんなときでもファイルのような動作をしてはなぜいけないのか? 警告は出さなくても構わないのでは?

バージョン 0.6 までの Julia では、全てのグローバルスコープが現在の REPL のように動作しました。x = <value> がループ (または try/catchstruct の本体) にあり、かつ関数の本体 (または let ブロックや内包表記) に含まれないなら、x がループにローカルかどうかはグローバル変数 x が定義されているかどうかで決まっていたということです。

この振る舞いは関数本体と似ているので、直感的で便利という利点があります。ただ欠点もあります。まず、この振る舞いはかなり複雑です: 説明を読んでもよく分からないという意見が長年にわたって多くの人から聞かれました。もっともな意見です。次に、間違いなくこれよりも悪い欠点として、「大規模な」プログラミングにおいてこの振る舞いは問題になります。次のようなコードが一つの場所に書かれていれば、何が起きているかは一目瞭然です:

s = 0
for i = 1:10
    s += i
end

このコードが既存のグローバル変数 s を更新しているのは明らかです。他に何を意味できるでしょうか? しかし、現実世界のコードはこれほど短くもなければ明快でもありません。私たちは次のようなコードに何度も遭遇しました:

x = 123

# ずっと後、あるいは別のファイル

for i = 1:10
    x = "hello"
    println(x)
end

# ずっと後、あるいはさらに別のファイル
# おそらく x = 123 のときに

y = x + 234

このコードの意図は明確ではありません。123 + "hello" はメソッドエラーなので、どうやら for ループ内の x はローカル変数として書かれたようです。しかし実行時の値とそのときに利用可能なメソッドを使って変数のスコープを決めることはできません。バージョン 0.6 以前の Julia であり得たのが、for ループを最初に書き、そこで目当てのコードを完成させ、それから遠く離れた別の場所 ──おそらくは別のファイル── をいじると最初のコードがエラーを出す、さらに悪いことにはエラーを出さずに振る舞いが変化するという現象です。この種の「不気味な遠隔作用」は優れたプログラミング言語が設計によって防ぐべきバグです。

これを受けて Julia 1.0 ではスコープの規則が単純化されました: 任意のローカルスコープにおいて、変数に対する代入は新しいローカル変数を作成するようになったのです。これによりソフトスコープという概念が完全に無くなり、不気味な遠隔作用も姿を消しました。ソフトスコープを削除したおかげで大量のバグが発見・修正され、この判断は正しかったのだと喜んだものです...

...しかし全員がそう思ったわけではなかったようです。一部の人は次のようなコードなど書きたくないと不満を口にしました:

s = 0
for i = 1:10
    global s += i
end

global という注釈に注目してください。あぁ恐ろしい、こんな見苦しいコードには耐えられない! ...ですが真面目に考えても、こういったトップレベルのコードに対して global を必須とする設計には二つの大きな問題があります:

  1. 関数の本体に含まれるコードをデバッグのために REPL へコピペしても、global 注釈を入れなければ正しく動きません。さらにデバッグした後は global を取り除かなければなりません。

  2. 初心者は global を入れ忘れたコードを書きがちですが、そのコードが予想通りに動かない理由を全く理解できません。そういったコードに対して表示されるエラーは「s が定義されていない」であり、これだけでは global を入れ忘れたことに気が付かないでしょう。

現在の Julia 1.5 では、このコードは REPL や Jupyter Notebook なら global 注釈無しに正しく動作します (つまり Julia 0.6 と同様です)。またファイルなどの非対話的な状況では、非常に直接的な警告が表示されます:

Assignment to s in soft scope is ambiguous because a global variable by the same name exists: s will be treated as a new local. Disambiguate by using local s to suppress this warning or global s to assign to the existing global variable.

(s という名前のグローバル変数が存在するので、このソフトスコープにおける s への代入は曖昧です。s は新しいローカル変数として扱われます。local s としてこの警告を消すか、global s として既存のグローバル変数への代入に変更してください。)

こうすると 1.0 が持つ「大規模プログラミング」におけるメリットを保持しつつ二つの問題を解決できます: グローバル変数が遠くのコードの意味を変えることはなく、デバッグのために REPL に貼り付けられたコードはそのまま動きます。さらに初心者が躓くこともありません: global 注釈を忘れたり、既存のグローバル変数をソフトスコープのローカル変数で隠したりすると、以前のような何も情報が得られない警告ではなく、丁寧で明快な警告を受けるからです。

この設計の重要な特徴が、ファイルから実行したときに警告が出ない任意のコードは新しい REPL で実行しても同じように振る舞うという事実です。逆に REPL で書いたコードをファイルに保存して実行すると振る舞いが変わる可能性があり、変わる場合には警告が発生します。

let ブロック

ローカル変数への代入とは異なり、let 文は実行されるたびに新しい変数束縛を作成します。代入は既に存在する名前の変数に対しては保存場所を使い回しますが、let は既に存在する名前の変数に対しても新しい保存場所を作成するということです。通常この違いは重要ではなく、唯一違いが目に見えて分かるのはクロージャによって変数が普通よりも長く生存したときだけです。

let の構文は変数の名前と代入の並びをコンマで分けて複数書くことを許しています:

julia> x, y, z = -1, -1, -1;

julia> let x = 1, z
           println("x: $x, y: $y") # x はローカル変数, y はグローバル変数
           println("z: $z") # z に値が代入されていないのでエラー (z はローカル変数)
       end
x: 1, y: -1
ERROR: UndefVarError: z not defined

let の最初に付く代入は順番に評価され、右辺の評価は左辺の変数がスコープに入る前に起こります。そのため let x = x と書いてもエラーではありません: 二つの x が異なる保存場所を指すためです。let が持つこの振る舞いが必要となる例を示します:

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           Fs[i] = ()->i
           global i += 1
       end

julia> Fs[1]()
3

julia> Fs[2]()
3

このコードでは変数 i を返す二つのクロージャを作成して保存しています。しかしクロージャに含まれる変数 i は同じなので、二つのクロージャは同じ値を返します。let を使って反復ごとに i に対する新しい束縛を作れば異なる値が返ります:

julia> Fs = Vector{Any}(undef, 2); i = 1;

julia> while i <= 2
           let i = i
               Fs[i] = ()->i
           end
           global i += 1
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

begin 構文は新しいスコープを作成しないので、新しい束縛を作らないゼロ引数の let に利用価値が生まれることもあります:

julia> let
           local x = 1
           let
               local x = 2
           end
           x
       end
1

let が新しいスコープブロックを作成するので、内側のローカル変数 x と外側のローカル変数 x は異なる変数です。

ループと内包表記

ループと内包表記 (comprehension) において、本体に含まれる新しい変数はループの反復のたびに新しく作成されます。これはループ本体が let ブロックで囲まれているかのような振る舞いであり、次の例の通りです:

julia> Fs = Vector{Any}(undef, 2);

julia> for j = 1:2
           Fs[j] = ()->j
       end

julia> Fs[1]()
1

julia> Fs[2]()
2

for ループおよび内包表記の反復変数は必ず新しい変数となります:

julia> function f()
           i = 0
           for i = 1:3
               # 何もしない
           end
           return i
       end;

julia> f()
0

しかし、既存のローカル変数を反復変数に使えると便利な場合がときにはあります。これはキーワード outer を加えると簡単に行えます:

julia> function f()
           i = 0
           for outer i = 1:3
               # empty
           end
           return i
       end;

julia> f()
3

定数

変数はよく特定の不変な値に名前を付けるのに使われ、そういった変数に対する代入は一度だけ行われます。キーワード const を使うとコンパイラにその意図を伝えられます:

julia> const e  = 2.71828182845904523536;

julia> const pi = 3.14159265358979323846;

一つの const 文で複数の変数を作ることもできます:

julia> const a, b = 1, 2
(1, 2)

const 宣言はグローバルスコープのグローバル変数に対してだけ使うべきです。グローバル変数の値 (そして型) は任意のタイミングで変更できるので、コンパイラはグローバル変数が絡むコードを上手く最適化できません。変更されないグローバル変数に const 宣言を追加すると、この性能の問題を解決できます。

ローカルな定数はこれと大きく異なります。コンパイラはローカル変数が定数かどうかを自動的に決定できるので、ローカル変数に対する定数宣言は不必要です。実はサポートもされていません。

functionstruct キーワードで行われる特殊なトップレベルの代入はデフォルトで定数を作成します。

const は変数の束縛だけに影響することに注意してください。可変オブジェクト (例えば配列) であっても定数を束縛することはできますが、その場合でもオブジェクト自体は変更できます。また定数として宣言された変数に別の値を代入するときの注意事項は次の通りです:

最後の規則は不変オブジェクトにも適用されますが、値が変化しない代入によって変数の束縛が変更される場合があります:

julia> const s1 = "1"
"1"

julia> s2 = "1"
"1"

julia> pointer.([s1, s2], 1)
2-element Array{Ptr{UInt8},1}:
 Ptr{UInt8} @0x00000000132c9638
 Ptr{UInt8} @0x0000000013dd3d18

julia> s1 = s2
"1"

julia> pointer.([s1, s2], 1)
2-element Array{Ptr{UInt8},1}:
 Ptr{UInt8} @0x0000000013dd3d18
 Ptr{UInt8} @0x0000000013dd3d18

これに対して可変オブジェクトでは期待通り警告が発生します:

julia> const a = [1]
1-element Array{Int64,1}:
 1

julia> a = [1]
WARNING: redefinition of constant a. This may fail, cause incorrect answers, or produce other errors.
1-element Array{Int64,1}:
 1

const 変数の値が変更可能なこともありますが、変更は強く非推奨とされます。想定されているのは対話的な状況における利用だけです。定数の変更は様々な問題や予期せぬ動作を引き起こします。例えば定数を参照するメソッドがコンパイルされた後に定数が変更されると、関数は古い値を使い続ける可能性があります:

julia> const x = 1
1

julia> f() = x
f (generic function with 1 method)

julia> f()
1

julia> x = 2
WARNING: redefinition of constant x. This may fail, cause incorrect answers, or produce other errors.
2

julia> f()
1
広告