なんか考えてることとか

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

Pythonで始めるプロトタイプベースOOP(2): より快適にする実装法

opaupafz2.hatenablog.com

前に、Pythonではクラスオブジェクトを扱うことで、プロトタイプベースなオブジェクト指向プログラミング(以下OOP)が可能であることを書いた。今回はその続編として、より快適にPythonでプロトタイプベースOOPが可能となるような実装を見つけたので書いていこうと思う。

前回のおさらい

まずPythonでは、クラスはオブジェクトである。そのため、クラスに対して直接操作することが可能である。そのうちの一つとして、新しい属性を定義できるというのがある。

>>> class Object:
...     pass
... 
>>> Object.name = 'Python'
>>> Object.hello = classmethod(lambda cls: print(f'Hello, {cls.name}!'))
>>> Object.hello()
Hello, Python!

今回定義した変数とメソッドを、それぞれクラス変数クラスメソッドと呼ぶ。クラス変数はclassブロック内に変数を直接定義する、クラスメソッドは@classmethodデコレータを使うことでも定義することができる。

class Object:
    name = 'Python'
    
    @classmethod
    def hello(cls):
        print(f'Hello, {cls.name}!')

>>> from prototypeoop import Object
>>> Object.hello()
Hello, Python!

次にPythonでプロトタイプベースにおけるクローンを実現するためには、継承を使う

class Object:
    pass

Object.name = 'Python'
Object.hello = classmethod(lambda cls: print(f'Hello, {cls.name}!'))

class Clone(Object):
    pass

Clone.goodnight = classmethod(lambda cls: print(f'Good night, {cls.name}...'))

>>> from prototypeoop import Object, Clone
>>> Clone.hello()
Hello, Python!
>>> Clone.goodnight()
Good night, Python...
>>> Object.goodnight()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'Object' has no attribute 'goodnight'

クローンオブジェクトの属性を確認してみると、クローン元となったオブジェクトの属性はなく、プロトタイプチェーンのようなものを使って属性を呼び出していることがわかる。

>>> from prototypeoop import Object, Clone
>>> for attr in Clone.__dict__.keys():
...     print(attr)
... 
__module__
__doc__
goodnight

以上から、PythonでもプロトタイプベースOOPが可能であることがわかった。
よりプロトタイプベースらしくするために、クラスの定義で以下のような実装を行った。

class Object:
    '''
    クローン元となるオブジェクト
    '''
    @classmethod
    def clone(cls):
        '''
        オブジェクトのクローン
        '''
        class Clone(cls):
            pass
        return Clone

>>> from prototypeoop import Object
>>> obj_1 = Object.clone()
>>> obj_1.name = 'Python'
>>> obj_1.hello = classmethod(lambda cls: print(f'Hello, {cls.name}!'))
>>> obj_1.hello()
Hello, Python!
>>> obj_2 = obj_1.clone()
>>> obj_2.goodnight = classmethod(lambda cls: print(f'Good night, {cls.name}...'))
>>> obj_2.goodnight()
Good night, Python...

今回の内容はここまでは一緒である。今回はこの段階から新しい実装を書いていく。

デコレータについて

まず今回の実装にあたり、知っておいてほしいのはPythonデコレータという機能である。
Pythonにおけるデコレータとは、高階関数に定義する関数を即時に渡して新しいオブジェクトを生成する機能である。

文章を見ただけではどういうことかわからないと思うので一つ一つ解説していこうと思う。

高階関数とは

高階関数とは、ざっくり言えば引数もしくは戻り値が関数である関数のことである。たとえば、以下のようなリストの全要素に不特定多数の関数を適用したリストを取得したい場合があったとする。

# なんか適当な0~9までのリスト
list_0to9 = [i for i in range(10)]

# この部分を不特定多数にしたい
result_list = [func(elem) for elem in list_0to9]

そのような場合には高階関数があると便利である。今回は関数名をlist_mapとする。

# 高階関数
def list_map(func, input_list):
    '''
    全要素に対して関数を適用したリストを返す
    '''
    # 全要素に対して関数を適用
    return [func(elem) for elem in input_list]

# 適当に関数を用意
def plus3(value):
    '''
    値に+3をする
    '''
    return value + 3
def toalpha(num):
    '''
    順番に適したアルファベット (a-z) を出力
    例) 0 -> 'a', 1 -> 'b', 18 -> 's'
    '''
    if num < 0 or num > 25:
        raise ValueError('0~25までの数を入れてください')
    return chr(ord('a') + num)

>>> from higher_order_func import *
>>> list_0to9 = [i for i in range(10)]
>>> list_map(plus3, list_0to9)
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
>>> list_map(toalpha, list_0to9)
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

Pythonにおける高階関数の例としてはmapfilterfunctoolsモジュールのreduceがある。

引数なしデコレータ

デコレータは、先ほどの高階関数を利用した機能である。
前に書いたことの繰り返しになるが、関数を定義すると同時に自動で高階関数に渡して新しいオブジェクトを生成してくれる
たとえば関数を定義する際にその結果に時刻を付与する関数を自動で生成したいとする。そのためのデコレータ@withdatetimeデコレータを作成してみる。

from datetime import datetime
from functools import wraps, reduce

def withdatetime(func):
    '''
    結果に時刻を付与する関数を生成する
    '''
    @wraps(func)
    def wrapper(*args, **kwargs):
        '''
        wrapper関数 (関数を別の関数で包むことを示す)
        '''
        return f'[{datetime.now()}] {func(*args, **kwargs)}'
    
    # wrapper関数を返すことで関数定義時にこのwrapper関数が定義される
    return wrapper

# 適当にデコレータを使って関数を定義
@withdatetime
def caesar_encode(string, shift):
    '''
    シーザー暗号を生成する
    例) ('abcd', 3) -> 'defg'
    '''
    return reduce(lambda acc, c: acc + chr(ord(c) + shift), string, '')

(※以下のPythonインタプリタは実際の結果とは異なるので注意)

>>> from withdatetime import caesar_encode
>>> caesar_encode('hello', 5)
'[yyyy-MM-dd hh:mm:ss.uuuuuu] mjqqt'

このデコレータを定義する関数で適用することをデコレートすると言う。今回はシーザー暗号を生成する関数を@withdatetimeでデコレートすることで、新しい関数を生成した。

# 適当にデコレータを使って関数を定義
@withdatetime
def caesar_encode(string, shift):
    '''
    シーザー暗号を生成する
    例) ('abcd', 3) -> 'defg'
    '''
    return reduce(lambda acc, c: acc + chr(ord(c) + shift), string, '')

ところで、上のコードは以下の糖衣構文である。

def caesar_encode(string, shift):
    '''
    シーザー暗号を生成する
    例) ('abcd', 3) -> 'defg'
    '''
    return reduce(lambda acc, c: acc + chr(ord(c) + shift), string, '')

# デコレートに該当する部分
caesar_encode = withdatetime(caesar_encode)

つまりやっていることは、一度関数を定義した後にwithdatetimecaesar_encodeを渡した結果を同名の変数に再代入しているのと同じである。
1個だけならこれでも良いかもしれないが、何個も作るとなると、いちいち関数を定義して再代入するのは面倒である。デコレータはそういう面倒なことを自動でやってくれる。

そしてここで重要なのがデコレータは関数だけでなく、いろいろなオブジェクトを生成することができるという点である。先ほども書いたように、デコレータは定義した関数と同名の変数に対してデコレートの結果を再代入しているだけである。
したがって以下のようなことも可能である。

def class_with_constructor(ctor):
    '''
    初期値指定なしコンストラクタで初期化したクラスを生成
    '''
    # クラスを生成
    class New:
        pass
    
    # コンストラクタを適用
    ctor(New)
     
    # クラスを返す
    return New

>>> from class_with_constructor import class_with_constructor
>>> @class_with_constructor
... def Object(cls):
...     cls.name = 'Python'
...     cls.hello = classmethod(lambda cls: print(f'Hello, {cls.name}!'))
... 
>>> Object.hello()
Hello, Python!

引数付きデコレータ

さて、ここまで書いてきて、デコレータに対してこういう不満はなかっただろうか。

デコレータに任意の引数を渡せればいいのに・・・

と。
実は引数付きのデコレータも作ることが可能である。先ほどのデコレータを脱糖したコードのこの部分に着目してほしい。

# デコレートに該当する部分
caesar_encode = withdatetime(caesar_encode)

くどいようで申し訳ないが、デコレータは定義した関数と同名の変数に対してデコレートの結果を再代入しているだけである。
では、高階関数を返す高階関数を使って、引数付きのデコレータは作れないだろうか?たとえばこんな感じで。

関数 = デコレータ(引数)(関数)

結論から言うと、可能である。以上をデコレータで表すと、以下のようになる。

@デコレータ(引数)
def 関数( ... ):
   ...

では、簡単な例として任意の数を加算する関数を生成するデコレータを作ってみる。

from functools import wraps

def plus_n(n):
    '''
    任意の数を加算する関数を生成するデコレータを生成する
    '''
    def _plus_n(func):
        '''
        任意の数を加算する関数を生成する
        '''
        @wraps(func)
        def wrapper(value):
            '''
            wrapper関数
            '''
            return func(value, n)
        
        return wrapper
    
    # 高階関数 (デコレータ) を返す
    return _plus_n

>>> from plus_n import plus_n
>>> # 値に+3加算する関数を生成
>>> @plus_n(3)
... def plus3(value, n):
...     return value + n
... 
>>> plus3(5)
8

メソッドスロット定義デコレータ

ようやく本題である。

以前の実装ではラムダ式以外の、途中式のある関数は@classmethodデコレータを使ってクラスメソッドを生成し、それをクラスの新しい属性として定義しなければならなかった。

obj_1 = Object.clone()

@classmethod
def hello(cls):
    print('Hello.')

obj_1.hello = hello

だが今回、デコレータを使うことで関数を定義すると同時に新しい属性として定義することができた。その実装を以下に示す。

class Object:
    '''
    クローン元となるオブジェクト
    '''
    @classmethod
    def clone(cls):
        '''
        オブジェクトのクローン
        '''
        class Clone(cls):
            pass
        return Clone
    
    @classmethod
    def methodslot(cls):
        '''
        メソッドスロットを定義するデコレータ
        '''
        def _methodslot(func):
            # setattr関数によりクラスに任意の名前の新しい属性を定義できる
            setattr(cls, func.__name__, classmethod(func))
        
        return _methodslot

まずクローンされるObjectクラスに新しくmethodslotクラスメソッドを定義した。

    @classmethod
    def methodslot(cls):
        '''
        メソッドスロットを定義するデコレータ
        '''
        def _methodslot(func):
            # setattr関数によりクラスに任意の名前の新しい属性を定義できる
            setattr(cls, func.__name__, classmethod(func))
        
        return _methodslot

このクラスメソッドをデコレータとして使っている。ただし、クラスメソッドは第一引数がクラスオブジェクトである関係上、引数付きデコレータとなる。そのためにこのデコレータ自体はデコレータを生成するデコレータとなっている。
そしてクラスに新しいクラスメソッドを定義するデコレータは以下の部分である。

        def _methodslot(func):
            # setattr関数によりクラスに任意の名前の新しい属性を定義できる
            setattr(cls, func.__name__, classmethod(func))

このsetattr関数はクラスに新しい属性を定義する関数である。要は以下と同じである。

cls.属性 = classmethod(関数)

なぜわざわざsetattr関数を使っているのかというと、関数名と同じ名前の属性を定義するためであるPythonJavaScriptとは違い、属性を辞書型の形で定義することはできないため、もし任意の名前の属性を定義したいのであれば、setattr関数を使う。
setattr関数を実行する際、第二引数にfunc.__name__があったかと思うが、これは関数オブジェクトの属性であり、名前通り関数の名前が格納されている。つまり適用した関数の名前が"hello"であれば、クラスにhelloという名前の属性が定義される

それでこのデコレータの使い方だが、以下のように使う。

>>> from prototypeoop import Object
>>> obj_1 = Object.clone()
>>> obj_1.name = 'Python'
>>> @obj_1.methodslot()
... def hello(cls):
...     print(f'Hello, {cls.name}!')
... 
>>> obj_1.hello()
Hello, Python!

先ほども言ったと思うが、このデコレータの実態は引数付きデコレータである。そのため、@obj_1.methodslot()の末尾の()は必須である。そこだけは注意が必要である。

コンストラクタ付きクローンデコレータ

次はコンストラクタ関数を定義すると同時にオブジェクトのクローンを行うデコレータを作成する。

こちらも以前ではあらかじめコンストラクタ関数を定義しておいて、そのコンストラクタ関数を渡すと同時に、残りの引数にコンストラクタ関数の引数に渡す変数を渡していた。

def constructor(cls, name):
    cls.name = name
    cls.hello = classmethod(lambda cls: f'Hello, {cls.name}!')

obj_1 = Object.clone(constructor, 'Python')

だがこれもデコレータを使うことで関数を定義すると同時に、クローンオブジェクトをコンストラクタで初期化することができた。その実装を以下に示す。

class Object:
    '''
    クローン元となるオブジェクト
    '''
    @classmethod
    def clone(cls):
        '''
        オブジェクトのクローン
        '''
        class Clone(cls):
            pass
        return Clone
    
    @classmethod
    def clone_with_constructor(cls, *args, **kwargs):
        '''
        コンストラクタ付きクローンデコレータ
        '''
        def initialize(ctor):
            '''
            コンストラクタ適用関数
            '''
            # オブジェクトのクローン
            Clone = cls.clone()
            
            # クローンオブジェクトをコンストラクタで初期化
            ctor(Clone, *args, **kwargs)
            
            return Clone
        
        return initialize
    
    @classmethod
    def methodslot(cls):
        '''
        メソッドスロットを定義するデコレータ
        '''
        def _methodslot(func):
            # setattr関数によりクラスに任意の名前の新しい属性を定義できる
            setattr(cls, func.__name__, classmethod(func))
        
        return _methodslot

追加したのは以下の部分である。

    @classmethod
    def clone_with_constructor(cls, *args, **kwargs):
        '''
        コンストラクタ付きクローンデコレータ
        '''
        def initialize(ctor):
            '''
            コンストラクタ適用関数
            '''
            # オブジェクトのクローン
            Clone = cls.clone()
            
            # クローンオブジェクトをコンストラクタで初期化
            ctor(Clone, *args, **kwargs)
            
            return Clone
        
        return initialize

まず見てほしいのはここである。

    @classmethod
    def clone_with_constructor(cls, *args, **kwargs):

今回も例によってクラスメソッドをデコレータとして使う。ただし、先ほどのメソッドスロット定義デコレータと違うのは、第二引数以降に可変長引数があることである。
ちなみに以前の実装では*argsだけだったが、これだと任意のキーワード引数に対して対処できないので、本来は**kwargsも追加しておくべきであった。申し訳ない。
この任意の引数*args, **kwargsにはコンストラクタで初期化する値を入れる。

次はコンストラクタでクローンオブジェクトを初期化する関数である。

        def initialize(ctor):
            '''
            コンストラクタ適用関数
            '''
            # オブジェクトのクローン
            Clone = cls.clone()
            
            # クローンオブジェクトをコンストラクタで初期化
            ctor(Clone, *args, **kwargs)
            
            return Clone

まず最初にオブジェクトのクローンを行い、ctorにクローンオブジェクトを適用している。このctorこそが、任意のコンストラクタ関数である
そして最後にコンストラクタで初期化したクローンオブジェクトを返している。

では最後にクローンすると同時にコンストラクタでクローンオブジェクトを初期化してみよう。

>>> from prototypeoop import Object
>>> @Object.clone_with_constructor('Python')
... def obj_1(cls, name):
...     @cls.methodslot()
...     def hello(cls):
...         print(f'Hello, {cls.name}!')
...     cls.name = name
... 
>>> obj_1.hello()
Hello, Python!

これでだいぶ快適にプロトタイプベースOOPができるようになったと言えるだろう。





というか、デコレータ便利すぎて草。