なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

Pythonで始めるプロトタイプベースOOP

Pythonのクラスは他言語のクラスと比べると少し異質なものである。その性質は、プロトタイプベースオブジェクト指向プログラミング(以下OOP)をするには十分である。というわけでPythonでプロトタイプベースOOPをしてみる。

Pythonはクラスをも一つのオブジェクトとして扱う

Pythonとりあえず何でもオブジェクトとして扱おうとする。クラスやモジュールもオブジェクトである。その中でもクラス、インスタンス、モジュール、関数(メソッドは×)には新しい属性を定義することができる。

今回、クラスを使うのでまずはクラスを定義してから定義済みのクラスに新しい属性を定義してみる。

>>> class Object:
...     pass
... 
>>> Object.attr_1 = 'Hello.'
>>> print(Object.attr_1)
Hello.

Objectクラスを定義した後attr_1属性に'Hello.'を代入しようとすると代入できることがわかる。print(Object.attr_1)で標準出力できることも確認できた。

もちろん関数やラムダ式も代入できる。

>>> def attr_2():
...     print('Call attr_2().')
... 
>>> Object.attr_2 = attr_2
>>> Object.attr_2()
Call attr_2().
>>> Object.attr_3 = lambda: print('Call attr_3().')
>>> Object.attr_3()
Call attr_3().

これらの属性は継承したクラスも呼び出すことができる

>>> class Derive(Object):
...     pass
... 
>>> print(Derive.attr_1)
Hello.

しかしこれらの属性は、継承することで派生クラスにも定義されるのではない。その証拠に、__dict__.keys()属性で全属性を確認できるのでそれらをfor(foreach)文を使って確認してみる。

>>> for key in Object.__dict__.keys():
...     print(key)
... 
__module__
__dict__
__weakref__
__doc__
attr_1
attr_2
attr_3
>>> for key in Derive.__dict__.keys():
...     print(key)
... 
__module__
__doc__

つまり派生クラスから基底クラスにあった属性を呼び出そうとすると基底クラスの属性が呼び出される、ということだ*1

・・・ん?

これってまんまプロトタイプベースじゃん

というわけで、PythonでもプロトタイプベースOOPが可能である。

本格的なプロトタイプベースらしいOOP

Pythonでプロトタイプベース的なOOPをするためにはクラス変数クラスメソッドが必要不可欠である。ちなみに先ほどObjectattr_1に文字列リテラルを代入することで新しく属性を定義したがこれぞまさしくクラス変数である。すなわちクラス変数は簡単に定義できる。

では、クラスメソッドはどうやって定義するのか?その前にそもそもクラスメソッドとは何なのかについて知る必要があるだろう。

クラスメソッドとは

クラス自身を扱うことができるメソッドである。クラス内で定義するメソッドはインスタンスメソッドと呼ばれるがこれはインスタンスを扱うことができるメソッドである。つまり平たく言えばクラスメソッドはインスタンスメソッドのクラス版である。

以下にクラスメソッドの例を示す。

class ClassMethod:
    '''
    クラスメソッドを扱うクラス
    '''
    # クラス変数
    name = 'Python'
    
    # クラスメソッド
    @classmethod
    def hello(cls):
        return 'Hello, ' + cls.name + '!'

if __name__ == '__main__':
    print(ClassMethod.hello())
Hello, Python!

この例を詳細に解説していく。

    # クラス変数
    name = 'Python'

クラス変数を定義している。クラス変数はクラスを定義するときにも定義ができる。それもclassブロック内に書けばOK。インスタンス変数よりも簡単に定義できる。

    # クラスメソッド
    @classmethod
    def hello(cls):
        return 'Hello, ' + cls.name + '!'

本題のクラスメソッド。一般的には@classmethodデコレータを使って第一引数をclsとするのが慣例である。第一引数clsは定義するクラス自身、すなわち今回の例で言えばClassMethodを示している。
これにより、本来であれば呼び出したときに自分自身のクラス変数を使っているとNameErrorとかになるが、問題なく呼び出すことが可能となる

if __name__ == '__main__':
    print(ClassMethod.hello())

そして最後にクラスメソッドを呼び出し、それを組み込み関数printで標準出力している。第一引数はクラス(自分)なので渡す必要はない
これにより、標準出力されたのが以下である。

Hello, Python!

ちなみにこのクラスメソッド、組み込み関数classmethodを使っても生成できる。したがって以下のような書き方も可能である。

>>> class ClassMethod:
...     pass
... 
>>> ClassMethod.name = 'Python'
>>> ClassMethod.hello = classmethod(lambda cls: 'Hello, ' + cls.name + '!')
>>> print(ClassMethod.hello())
Hello, Python!

こうして見ると実はPythonってJavaScriptよりもIoのような純粋なプロトタイプベースに近いのではないか?と思ってしまう。

しかしながら残念なことにPythonにはクラスオブジェクトをクローンする機能は標準でついていない。それを実現するためには継承を必要とする。
いちいち継承をするのも面倒くさいので、クラスオブジェクトにcloneメソッドを実装しようと思う。

cloneメソッドの実装

さて、こう言っては何だが実はcloneメソッドを実装するのは非常に簡単である。なぜなら派生クラスを返せば良いからである。

cloneメソッドの実装を以下に示す。

class Object:
    '''
    cloneメソッド付きクラス
    '''
    # cloneメソッド
    @classmethod
    def clone(cls):
        '''
        クラスオブジェクトのクローン
        '''
        # クラスメソッド内部に自身の派生クラスを定義する
        class Clone(cls):
            pass
        
        # 派生クラスを返す
        return Clone

第一引数clsにはクラスオブジェクトが渡されるのでclsを継承してCloneクラスオブジェクトを生成し、それを返している。

ではcloneメソッドを使ってみよう。

>>> Object_1 = Object.clone()
>>> Object_1.hello = classmethod(lambda cls: 'Hello, ' + cls.name + '!')
>>> Object_1.name = 'Python'
>>> print(Object_1.hello())
Hello, Python!

これは本当にクラスなのか?と疑ってしまうかもしれないが、インスタンスを生成できる。よってこれはクラスである。

>>> instance = Object_1()
>>> print(instance.hello())
Hello, Python!

ちなみにcloneメソッドを使ったときにクラスを継承するが、当然cloneメソッドも継承されるので、クローンしたクラスオブジェクトも呼び出すことが可能である。

>>> Object_2 = Object_1.clone()
>>> print(Object_2.hello())
Hello, Python!

cloneメソッドに初期化機能を追加する

しかし、cloneメソッドだけではクラスオブジェクトをクローンした後も新しく属性を定義しなければならない。せめてコンストラクタ関数のようなことはできないものか。

ということでコンストラクタ関数を受け入れてくれるようにObjectクラスに初期化機能を追加してみる。

class Object:
    '''
    cloneメソッド付きクラス
    '''
    # cloneメソッド
    @classmethod
    def clone(cls, ctor=None, *args):
        '''
        クラスオブジェクトのクローン(初期化機能付き)
        '''
        # クラスメソッド内部に自身の派生クラスを定義する
        class Clone(cls):
            pass
        
        # コンストラクタ関数を適用
        if ctor is not None:
            ctor(Clone, *args)
        
        # 派生クラスを返す
        return Clone

まず変更したのはcloneメソッドの引数である。

    def clone(cls, ctor=None, *args):

クラスを示すcls以外にもctorargsがある。
ctorが適用するコンストラクタ関数で、デフォルトだとNoneになっているデフォルト引数である。そしてargsはコンストラクタ関数の引数を示し、いくらでも渡すことができる可変長引数である。

次にコンストラクタ関数適用部分を見てみる。

        # コンストラクタ関数を適用
        if ctor is not None:
            ctor(Clone, *args)

ctorNoneでなかったとき、つまりコンストラクタ関数があったときにこの処理は実行される。そして残りの引数は*argsとなっているが、これは可変長引数にパラメータを渡したときタプル型となっているのだがそれを分解して、固定長の引数として使う。

ではこれをもとにコンストラクタ関数を作ってクローンするクラスオブジェクトに適用する。

>>> def constructor(cls, name, year):
...     cls.name = name
...     cls.year = year
...     cls.fst_release = classmethod(lambda cls: f'The first release of {cls.name} was in {cls.year}.')
... 
>>> Object_3 = Object.clone(constructor, 'Python', 1991)
>>> print(Object_3.fst_release())
The first release of Python was in 1991.

Python、自由度が高いなぁ。PEPではこのような手法は推奨されていないと思うけど。

追記(2021/10/3): 続編書きました

opaupafz2.hatenablog.com

*1:ちなみに__dict__と__weakref__も基底クラスにだけ存在する属性である