肉球でキーボード

MLエンジニアの技術ブログです

python のプライベート変数を使う場面

private 変数とは?

Python には外部からアクセスができない厳密な意味で private な機能は用意されていません。
代わりに、アンダースコアを属性名の先頭につけることで、プログラマに private な属性であることを明示する機構があります。
PEP8 の命名方法を見てみると、private な属性を表す変数名に二つの方法があります。 はじめに — pep8-ja 1.0 ドキュメント

_single_leading_underscore: "内部でだけ使う" ことを示します。 たとえば from M import * は、アンダースコアで始まる名前のオブジェクトをimportしません。

__double_leading_underscore: クラスの属性に名前を付けるときに、名前のマングリング機構を呼び出します (クラス Foobar の boo という名前は _FooBarboo になります。以下も参照してください)

なんとなく分かるけど、じゃあ実際に使う時はどんな場面か想像しづらいと思うので説明したいと思います。
二つの命名方法は異なるユースケースで使用されるので、別々に説明したいと思います。

_single_leading_underscore

Python 公式ドキュメントの private 変数の項目の内容を見ると次のようになっています

9. クラス — Python 3.7.10 ドキュメント

オブジェクトの中からしかアクセス出来ない "プライベート" インスタンス変数は、 Python にはありません。しかし、ほとんどの Python コードが従っている慣習があります。アンダースコアで始まる名前 (例えば _spam) は、 (関数であれメソッドであれデータメンバであれ) 非 public なAPIとして扱います。これらは、予告なく変更されるかもしれない実装の詳細として扱われるべきです

非publicなAPIで、予告なく変更されるかもしない実装の詳細とは具体的にどういうことを言っているのでしょうか?
リストの合計の和をとるクラスを作成して、APIとして公開する場面を想定して説明したいと思います。
MyClasssummarize メソッドは、リストの和をとる機能を備えたAPIとして使用者が利用することを想定して用意されています。

from typing import List


class MyClass:
    def _summarize(self, contents: List[float]) -> float:
        # 1. 型チェック
        assert isinstance(contents, list)

        # 2. 要素を一つずつ足しあわせていく実装
        sum_number = 0
        for value in contents:
            sum_number += value

        # 3. 型変換
        sum_number = float(sum_number)

        return sum_number

    def summarize(self, contents: List[float]) -> float:
        return self._summarize(contents)

API開発者としてはリストの和を取る過程で、1.型チェック, 2. リストの要素を足し合わせ, 3. 型変換 という3つの機能を内部に入れたいわけですが、API利用者には内部実装を意識せずに利用してもらいたい思いがあります。
例えば、リストの合計をとる実装をndarray に変換して和をとる方法に変えたいと思った場合、API利用者は summarize メソッドをimport した際に新たな変数が内部で使用されていることに戸惑います。

# 2. ndarray に変換して和をとる方法
new_sum_number = numpy.sum(contents)

このような場合にプライベートメソッドを利用することで、API開発者はAPI利用者に提供したいインターフェイスは変えずに、内部の実装を柔軟に変更することができるようになります。
scikit-learn のLinearModel のpredict メソッドの実装を参考にすると、プライベートメソッドに内部実装を行い、API利用者はバージョンアップによる内部実装の変化を意識せずに predict メソッドを使用することができます。

scikit-learn/_base.py at 517b38ad30f36c6fe9eaca2c4a496ac1c63b3f50 · scikit-learn/scikit-learn · GitHub

class LinearModel(BaseEstimator, metaclass=ABCMeta):
    """Base class for Linear Models"""

    @abstractmethod
    def fit(self, X, y):
        """Fit model."""

    def _decision_function(self, X):
        check_is_fitted(self)

        X = self._validate_data(X, accept_sparse=["csr", "csc", "coo"], reset=False)
        return safe_sparse_dot(X, self.coef_.T, dense_output=True) + self.intercept_

    def predict(self, X):
        """
        Predict using the linear model.
        Parameters
        ----------
        X : array-like or sparse matrix, shape (n_samples, n_features)
            Samples.
        Returns
        -------
        C : array, shape (n_samples,)
            Returns predicted values.
        """
        return self._decision_function(X)

__double_leading_underscore

PEP8 のメソッド名のインタンス変数の項目にダブルアンダースコアについての説明を取り上げると、以下のように書かれています。
はじめに — pep8-ja 1.0 ドキュメント

サブクラスと名前が衝突した場合は、Python のマングリング機構を呼び出すためにアンダースコアを先頭に二つ付けてください。
Python はアンダースコアが先頭に二つ付いた名前にクラス名を追加します。つまり、クラス Foo に __a という名前の属性があった場合、この名前は Foo.__a ではアクセスできません (どうしてもアクセスしたいユーザーは Foo._Foo__a とすればアクセスできます)。一般的には、アンダースコアを名前の先頭に二つ付けるやり方は、サブクラス化されるように設計されたクラスの属性が衝突したときに、それを避けるためだけに使うべきです

アンダースコア2つを先頭につける場合は、「サブクラスとの名前を避ける場合のみ」に使用します。アンダースコア1つの場合と明確に目的が異なるので、注意してください。
では、サブクラスとの名前衝突がどのようなケースのことを指しているのか、マングリング機構とは何か説明したいと思います。

マングリング機構

Python ではアンダースコアを変数の先頭につけると、クラス外部からのアクセスができなくなります。
例えば下のクラスのように、インスタンス変数にプライベート変数 __private_field を定義した場合、クラスインスタンスのメソッド経由でならプライベート変数にアクセスできますが、直接プライベート変数へアクセスをしようとすると AttributeError になります。

class MyObject:
    def __init__(self):
        self.public_field = 5
        self.__private_field = 10 # プライベート変数の定義

    # クラス内部でプライベート変数にアクセス
    def get_private_field(self):
        return self.__private_field

foo = MyObject()

# クラス内部でプライベート変数を呼び出しているので、メソッド経由でアクセスしている
assert foo.get_private_field() = 10 

# プライベート変数にクラスの外側からアクセスできない。 
assert foo.__private_field = 10 
> AttributeError: 'MyObject' object has no attribute '__private_field'

プライベート変数を持つクラスを継承したサブクラスで、プライベート変数にアクセスすることはできません。
例えば、以下のようにサブクラス内部で親クラスで定義したプライベート変数にアクセスしようとすると、AttributeError となります。

class MyParentObject:
    def __init__(self):
        self.__private_field = 31

class MyChildObject(MyParentObject):
   # サブクラス内部から、親クラスのプライベート変数へアクセス (ダメな例)
    def get_private_field(self):
        return self.__private_field

baz = MyChildObject()

# サブクラス内部で親クラスのプライベート変数にアクセスできない
baz.get_private_field()
> AttributeError: 'MyChildObject' object has no attribute '_MyChildObject__private_field'

ここで注目すべきは、サブクラスでプライベート変数にアクセスしようとすると、Python_MyChildObject__private_field という変数にアクセスしようとしてることです。これは Python コンパイラがプライベート変数アクセスを、__private_field の代わりに、_MyChildObject__private_field にアクセスするように変換していることによります。このようなプライベート変数へのアクセス名が暗黙的に変わる機構をマングリング機構といいます。今回の例では、__private_fieldMyParentObject.__init__ で定義されているため、サブクラスでの__private_field の属性名は _MyParentObject__private_field となります。このような名前変換がわかると、サブクラスから親クラスのプライベート変数にアクセスすることができます。

# サブクラスから変換後の親クラスのプライベート変数にアクセスできる
print(baz._MyParentObject__private_field)

# オブジェクトの属性辞書に変換後のプライベート変数が登録されている
print(baz.__dict__)
> {'_MyParentObject__private_field': 10}

プライベート変数を使う場面

Python のプライベート変数を使うのは、サブクラスとの名前の衝突を避ける心配がある時だけです。
名前衝突の例を下にあげます。この例では、親クラスで定義した _value と同じ変数名をサブクラスでもつけてしまい、変数の中身が意図せず書き換えられてしまっています。

class ApiClass:
    def __init__(self):
        self._value = 5

    def get(self):
        return self._value

class Child(ApiClass):
    def __init__(self):
        super().__init__()
        self._value = 'hello' # 衝突

a = Child()
print(f'{a.get()} and {a._value} should be differenct')
> hello and hello should be difference

このような名前衝突は、サブクラス側ではコントールが難しく、公開API側の一部であるクラスの問題と言えます。例に使われた value な、頻繁に使われる変数名でよく衝突が起きるので注意が必要です。このような衝突が起こるリスクを低減させるために、親クラスでプライベート変数を用いて、サブクラスと重複する変数名がないようにします。プライベート属性を用いることで、以下のように公開APIの変数を保護することが可能となります。

class ApiClass:
    def __init__(self):
        self.__value = 5 # double underscore

    def get(self):
        return self.__value # # double underscore

class Child(ApiClass):
    def __init__(self):
        super().__init__()
        self._value = 'hello' 

a = Child()
print(f'{a.get()} and {a._value} should be differenct')
> 5 and hello should be differenct

参考