【Python】クラスの特性、クラス・関数のスコープの特殊性

Python

※この記事は、Pythonのクラスの特殊性とスコープについて私が独自に検証・調査した記事ですが、私自身がPythonのスペシャリストと言える程の腕前の者ではないので、内容はあくまでご参考程度にお願い致します。ただ、指摘は歓迎します。

ちなみに、検証に用いたPythonのバージョンは3.9.1です。

発端

Python言語の創始者であるGuidoさんが、ある時、↓のツイートをしていました。

どういうことかと言うと、↓のコードを実行すると、結果として「0 1」が出力されます(3.9.1で実行)。

x = 0
y = 0
def f():
    x = 1
    y = 1
    class C:
        print(x, y)  # What does this print?
        x = 2
f()

仮に上から順に実行されるとして、素直に考えると、「1 1」が出力されそうに思います。ですが、そうではありません。

なぜでしょうね…?

この記事では、Pythonのスコープの特殊性に関して、調べたことをまとめています。

疑問点の整理

  1. インスタンス化されていないクラス(Cクラス)の中が、なぜ実行されているのか?
  2. なぜ、xとyで結果の値が異なるのか?

1は知ってる人は知っているのかもしれません。

実行順序については、

  1. x = 0
  2. y = 0
  3. f( )

のはずですね。

Pythonのクラスの特性

オブジェクト指向(OOP)では、クラスの要素としては変数ないし関数(やプロパティ)が定義されることが普通だと思うので、定義の直下にprintなどと書くケースは不自然に思えます。処理を定義して使い回すなら、関数(上の例で言えばdef)を使用するのが自然でしょう。

ただ、Pythonはクラス定義の直下に直接printなどの処理を記述できるようです(不思議なことに)。

例えば、↓のようなコードを実行してみます。

class C:
    x = 'inside Class C'
    print(x)

def F():
    x = 'inside Function F'
    print(x)

a = C()
print(a.x)
F()

結果は、こうなります。

inside Class C
inside Class C
inside Function F

class Cは定義時点で一度実行されていることが分かります。関数F()は定義時点では実行されていません(呼び出した時に実行されている)。

ここから分かるクラスの特性として、

  • クラスは定義段階で中の処理が実行される
  • クラス定義直下に処理(C#で言うステートメント)を記述できる

ただ、こういう、複雑な書き方は普通しないと思います。Pythonのクラスはちょっと不思議ですね(何か理由があるのかもしれませんが…そこまでは調べていません)。(1)の回答としては、こんなところでしょうか。

クラスのスコープにまつわる不思議

もう1つの疑問は、「なぜ、xとyで結果の値が異なるのか?」でした。

念のため補足すると、関数の下にクラスを書き、そのクラスを関数の如く実行するという変(というか複雑)な作り方は、普通はしないと思います。コードが大規模かつ複雑になってくると、意図せず作り込んでしまう可能性はありそうですが。

ただ、Pythonのこの辺の特殊性を事前に把握しておくことで、意図せぬバグの作り込みを防げるかもしれません(あと、私の凝り性の性格もあるので調べた)。

ヒントになりそうな情報源

英語になりますが、stack overflowにあった以下の質問・回答が参考になるかもしれません。質問のコードも、今回↑で挙げたものと、ほぼ同様です。

This behaviour has existed since Python 2.1 PEP 227: Nested Scopes, and was known back then. If a name is assigned to within a class body (like y), then it is assumed to be a local/global variable; if it is not assigned to (x), then it also can potentially point to a closure cell.

Python class scoping rules

要するに、

  • Python 2.1から存在する挙動
  • クラス内部で変数の割当があるとローカルもしくはグローバルな変数と見なされる
  • 割当がない場合はclosure cellを指し示すことがある(これは意味がよく分からん)

closure cellは、要するにクロージャのまとまりのことであると解釈してよいでしょう。クロージャについてはPython特有の概念ではないので、詳しくはググって頂くか、クロージャ(Wikipedia)をご覧下さい。要するに、関数の中に関数を書くという形で、参照の範囲を限定するものであるようです(実務で使用した経験はありませんが)。

もう1つ、Python公式サイトのドキュメントにヒントがありました。上の引用のリンクですが、PEP 227: 入れ子状のスコープです。PEPはPython Enhancement Proposaの略ですが、詳しくは公式サイトのWhat is a PEP?をご覧下さい。

Python 2.1の最も重要な変更点は、この問題を解決するために静的なスコープが追加されたことです。. . . (中略). . . 簡単に言えば、指定された引数名が関数内の値に割り当てられない場合(defclass または import ステートメントの割り当てによって)、変数の参照は外側のスコープのローカル名前空間で検索されます。ルールや実装の詳細はPEPで参照できます。

PEP 227: 入れ子状のスコープ

静的スコープ(英語ではlexical scope, static scopeと呼ぶらしい)がバージョン2.1から導入され、こういう挙動になり(上で書いた通り)、変数の割当がない場合は外側のスコープで名前が検索されるらしいです。最初に↑で挙げた例で言えば、yclass Cで割当されていないのでdef f()の値が使用される、ということになるようです。

バグではなく、こういう挙動(要するに仕様)である、ということですね。

ちなみに、PEP 227では、こういう説明も付け加えられています。問題を起こしうる書き方ですが、そもそも、そういうコーディングをする時点で問題があります。

この変更は、同じ変数名がモジュールレベルと関数の定義が含まれている関数内のローカルの両方で変数名として使用されているコードで、互換性の問題を引き起こす可能性があります。ですがむしろ気にしなくてよいでしょう。そのようなコードはそもそも最初から相当こんがらかっているので。

補足(内部関数の場合を試してみる)

クラスを関数に変えてみると、どうなるでしょうか。

関数の下のクラスを関数に変えてみましたが、これはエラーになります。

# 動かない例

x = 0
y = 0
def f():
    x = 1
    y = 1

    def sub_f():
        print(x, y)
        x = 2
    
    sub_f()

f()

こんなエラーメッセージが出る。

UnboundLocalError: local variable 'x' referenced before assignment

ただ、x = 2 を無くすと、クロージャとして機能してくれるようです。「1 1」が出力結果として、帰ってきます。

# 動く例

x = 0
y = 0
def f():
    x = 1
    y = 1

    def sub_f():
        print(x, y)
        # x = 2
    
    sub_f()

f()

Python 2.1は2001年4月に公開されたとのことですが、Python(というかCPython)って実は歴史的な積み重ねが色々とあるのですね。

静的スコープや動的スコープについては、また別の記事にまとめてみたいと思います(暇があれば)。

以上です。