Pythonまとめ(3.クラスとデコレータ)

独自クラス

基本的な構文

class CounterClass(superclass): # スーパクラスがない場合(objの場合)は()ごと省略可能
    total = 0                   # クラス変数(すべてのインスタンスで共通の変数)
    def __init__(self, name):   # コンストラクタ
        self.name = name        # インスタンス変数
    def getName(self):          # インスタンスメソッド
        return self.name
    @classmethod
    def getCount(cls):          # クラスメソッド
        return cls.total
    @staticmethod
    def convertFrom():          # スタティックメソッド
        pass # 何らかの処理
Python

親クラスの取得

super()関数で親クラスのメソッドを参照できる。コンストラクタで親クラスのコンストラクタを呼び出す例を示す。

    def __init__(self, name):   # コンストラクタ
        super().__init__(name)
Python

可視性

Pythonでは各メソッドや変数の可視性を指定できない。代わりにアンダースコア_から始まる名前にすることでその変数やメソッドは外部から利用することを想定していないことを示す。通常はアンダースコアで始まる関数や変数を外部から参照するべきでない

class Human:
    _name = "example"

Human._name
'example'
Python
マングリングと可視性

アンダースコア2つ__で始まる変数名はマングリング(名前修飾)が行われる。classname.__attributeはclassname._classname__atributeのように_クラス名が付与される。その結果として外部からのアクセスが意図して実装しない限りできなくなる。

class Human:
    __name = "example"

Human.__name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'Human' has no attribute '__name'
Human._Human__name
'example'
Python

ただし、マングリングは継承クラスとの名前の重複を防ぐための仕組みであり、外部からの隠蔽を目的とする機構ではない。また、厳密にはマングリングはアンダースコア2つ以上で始まり、アンダースコア1つ以下で終わる名前に適用される。なので__name__はマングリングの対象とはならない。

名前の重複

Readerクラスと、Readerクラスを継承したCSVReaderクラスを以下の通り作成した。これはマングリングを使用していない。

class Reader:
    def _readdata(self):
        print("Reader->readdata")

    def read(self):
        self._readdata()

class CSVReader(Reader):
    def _readdata(self):
        print("CSVReader->readdata")
        super().read()

    def read(self):
        self._readdata()

Python

ここでCSVReader.read()を呼び出すとRecursionErrorが発生する。

creader = CSVReader()
creader.read()
CSVReader->read
CSVReader->readdata
Reader->read
CSVReader->readdata
Reader->read
CSVReader->readdata
Reader->read
(省略)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 8, in read
  File "<stdin>", line 4, in _readdata
  File "<stdin>", line 7, in read
  File "<stdin>", line 4, in _readdata
  File "<stdin>", line 7, in read
(省略)
RecursionError: maximum recursion depth exceeded
Python


出力から以下のような動作をしていることが分かる。

  1. CSVReader->read が呼び出される
  2. CSVReader->readdata が呼び出される
  3. Reader->readが呼び出される
  4. CSVReader->readdata が呼び出される
  5. Reader->readが呼び出される
  6. 以下繰り返し

これはReader.read()が呼び出すself._readdata()が、Readerクラスの_readdataメソッドではなく、CSVReaderクラスで定義した同名の_readdataメソッドを指すからである。マングリングを使用することでこのような名前重複を防ぐことができる。

class Reader:
    def __readdata(self):
        print("Reader->readdata")

    def read(self):
        print("Reader->read")
        self.__readdata()

class CSVReader(Reader):
    def __readdata(self):
        print("CSVReader->readdata")
        super().read()

    def read(self):
        print("CSVReader->read")
        self.__readdata()

creader = CSVReader()
creader.read()
CSVReader->read
CSVReader->readdata
Reader->read
Reader->readdata
Python

プロパティ

プロパティはメンバ変数のようにアクセスできる関数である。getterやsetter付きのメンバ変数と考えてもいいだろう。@propertyデコレータをつけたメソッドがそのプロパティのgetterとなり、@propertyname.setterデコレータをつけたメソッドがそのプロパティのsetterとなる。

プロパティへのアクセスは()をつけずに、インスタンス変数と同じようにアクセスする。

class PropertyTest:
    def __init__(self) -> None:
        self._balance = 0

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self,value):
        if value<0: raise ValueError()
        self._balance = value

proptest = PropertyTest()
proptest.balance
0
proptest.balance = 10
proptest.balance
10
Python

@propertyデコレータはpropertyクラスのオブジェクトを作成している。以下のコードは上と同等の実装である。

class PropertyTest2:
    def __init__(self) -> None:
        self._balance = 0

    def getbalance(self):
        return self._balance

    def setbalance(self, value):
        if value<0: raise ValueError()
        self._balance = value

    # 引数は順にgetter, setter, deleter, docstring
    balance = property(getbalance, setbalance, None, None)

Python

プロパティのユースケースとしては以下のようなものが考えられる。

  • 値を設定するときにチェックを行う
  • 他の属性値の影響で値が変わる

他の属性値の影響を受けるプロパティの例を示す。

class PropertyNameTest:
    def __init__(self, firstname, familyname) -> None:
        self.firstname = firstname
        self.familyname = familyname

    @property
    def fullname(self):
        return f"{self.firstname} {self.familyname}"

proptest = PropertyNameTest("Ned", "Peyton")
proptest.fullname
'Ned Peyton'
Python

特殊メソッド

Pythonでは特殊メソッド(スペシャルメソッド)を実装することでオブジェクトの動作を定義できる。例えば数値的な要素を持つクラスであれば__add__メソッドを実装することで演算子+による加算が行えるようになる。

インスタンスの生成・削除に関する特殊メソッド

用途メソッド呼び出し例説明
インスタンスの生成object.__new__( cls, ... )obj = Class()新しいインスタンスを作成するときに使う。通常は作成するクラスのインスタンスを返すが、異なるクラスのインスタンスを返しても良い。__new__が異なるクラスのインスタンスを返した場合__init__は呼び出されない。
実装しない場合、親クラスの__new__が呼び出される。
通常は__init__でオブジェクトを初期化するため、__new__はあまりカスタマイズしない。
インスタンスの初期化object.__init__( self, ... )obj = Class()__new__によって作成されたオブジェクトの初期化を行う。
実装しない場合、親クラスの__init__が呼び出される。
インスタンスの削除object.__del__( self )del objインスタンスが破棄されるときに呼び出される。ファイナライザやデストラクタとも呼ばれる。
補足:呼び出し例のdel xは正確にはxの参照カウントを減らすものであり、参照カウントが0になったときにx.__del__()が呼び出される。

__new__メソッドの実装の違いにより、__init__メソッドが呼び出される場合とそうでない場合の例を示す。Sample1クラスの__init__メソッドは数値リストを返しているため、__init__メソッドが呼び出されない。

class Sample1():
    def __new__(cls):
        print("new")
        return [1, 2, 3]

    def __init__(self): # この__init__は呼び出されない
        print("init")

class Sample2():
    def __new__(cls):
        print("new")
        return super().__new__(cls)

    def __init__(self):
        print("init")

Sample1()
new
[1, 2, 3]
Sample2()
new
init
<__main__.Sample2 object at 0x000001F1465C0470>
Python

文字列・バイト列に変換する特殊メソッド

用途メソッド呼び出し例説明
公式の文字列に変換object.__repr__( cls, ... )objオブジェクトの「公式の」文字列を計算する。この文字列は同じオブジェクトを再生成できるような表現が望ましい。できないなら"<何らかの情報>"の形式の文字列を返すべきである。
これはデバッグでも用いられるため、曖昧でない十分な情報を含むことが重要である。
非公式の文字列に変換object.__str__( self, ... )str(obj)オブジェクトの「非公式の」文字列を計算する。__repr__とは異なり、ユーザフレンドリーな(簡潔な)文字列を使用して良い。デフォルトでは__repr__を呼び出す。
バイト列に変換object.__bytes__( self )bytes(obj)オブジェクトのバイト列表現を計算する。
フォーマットされた文字列に変換object.__format__( self, format )f'{obj:+,}'フォーマット文字列などで指定されたフォーマットどおりの文字列を計算する。

公式(repr)と非公式(str)の違いはdatetimeの実装を見るとわかりやすい。datetimeにおいてreprが返す文字列は、実行可能なPythonコードとなっている。このコードを実行すると同値のオブジェクトが得られる。このような条件さえ整えば同じオブジェクトを再生成できるような値が望ましいのがreprである。

import datetime

now = datetime.datetime.now()
repr(now)
'datetime.datetime(2024, 12, 22, 21, 41, 11, 88599)'
str(now)
'2024-12-22 21:41:11.088599'

# 文字列をPythonコードとして実行するeval関数にreprの結果を渡す
eval( repr(now) )
datetime.datetime(2024, 12, 22, 21, 41, 11, 88599)
Python

比較に関するメソッド

用途メソッド呼び出し例説明
より小さいobject.__lt__( self, other )obj < other
小さいか等しいobject.__le__( self, other )obj <= other
等しいobject.__eq__( self, other )obj == other
等しくないobject.__ne__( self, other )obj != other
大きいobject.__gt__( self, other )obj > other
大きいか等しいobject.__ge__( self, other )obj >= other
ハッシュobject.__hash__( self )hash( obj )dictなどでも使われる
論理object.__bool__( self )bool( obj )オブジェクトそのものの真偽判定

obj > otherを実行したときに、もしotherがobjのサブクラスの場合はotherのメソッドが優先される。つまりobj.__gt__(other)ではなく、その対となるother.__le__(obj)が実行される。

数値演算に関するメソッド

self + otherのような二項演算子でも特殊メソッドobject.__add__(self, other)が呼び出される。これを実装することで数値のように動作するクラスを作成できる。すべての数値演算に関するメソッドはPython3 Documentation 数値型をエミュレートするを参照すること

属性に関するメソッド

オブジェクトに関連付けられ、通常ドットを使用してobject.nameの形でアクセスできるものを属性またはアトリビュートとよぶ。属性へアクセスすると内部的には以下のメソッドが呼び出されている。

用途メソッド呼び出し例説明
属性への代入object.__setattr__( self, name, value )obj.x = 5
属性へのアクセスobject.__getattribute__( self, name )obj.x属性にアクセスするときに呼び出される。
属性の削除object.__delattr__( self, name )del obj.x

これらのメソッドをカスタマイズすることで挙動を変更できる。メソッドの呼び出し状況を確認するために標準出力するように変更してみる。

from typing import Any

class Item(object):
    def __init__(self) -> None:
        self.stock = 0

    def __setattr__(self, __name: str, __value: Any) -> None:
        print(f"__setattr__ called:{__name},{__value}")
        super().__setattr__(__name,__value)

    def __getattribute__(self, __name: str) -> None:
        print(f"__getattribute__ called:{__name}")
        return super().__getattribute__(__name)

    def __delattr__(self, __name: str) -> None:
        print(f"__delattr__ called:{__name}")
        super().__delattr__(__name)


i = Item()
__setattr__ called:stock,0
i.stock = 100
__setattr__ called:stock,100
i.stock
__getattribute__ called:stock
100
del i.stock
__delattr__ called:stock
i.name = "ItemName"
__setattr__ called:name,ItemName
Python

__setattr__メソッドは定義されていない属性であれば自動で追加する動作がデフォルトである。なので最後のi.name = "ItemName"はエラーにならない。一方で__getattribute__メソッドは定義されていない属性だとAttributeErrorが発生する。

i = Item()
__setattr__ called:stock,0
i.name
__getattribute__ called:name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 11, in __getattribute__
__getattribute__ called:__dict__
__getattribute__ called:__class__
AttributeError: 'Item' object has no attribute 'name'
Python

もしクラスに__getattr__メソッドが定義されている場合、存在しない属性にアクセスした場合(主に__getattribute__でAttributeErrorが発生した場合)に__getattr__メソッドが呼び出される。そこでデフォルト値を定義するなどの挙動を制御できる。なお、__getattribute__は、アクセスしようとした属性の値を返すか、AttributeErrorを送出しなければならない。

from typing import Any

class Item(object):
    def __init__(self) -> None:
        self.stock = 0

    def __setattr__(self, __name: str, __value: Any) -> None:
        print(f"__setattr__ called:{__name},{__value}")
        super().__setattr__(__name,__value)

    def __getattribute__(self, __name: str) -> None:
        print(f"__getattribute__ called:{__name}")

    def __delattr__(self, __name: str) -> None:
        print(f"__delattr__ called:{__name}")
        super().__delattr__(__name)

    def __getattr__(self, __name: str):
        print(f"__getattr__ called:{__name}")
        match __name:
            case "stock":
                self.stock = 0
                return self.stock
            case "name":
                self.name = "Void"
                return self.name
            case _:
                raise AttributeError(f"Unsupported attribute:{__name}")

i = Item()
__setattr__ called:stock,0
i.stock
__getattribute__ called:stock
0
i.name
__getattribute__ called:name
__getattr__ called:name
__setattr__ called:name,Void
__getattribute__ called:name
'Void'


Python

属性へのアクセスは組み込み関数getattr、setattrでも行える。getattrでは属性が存在しないときのデフォルト値を指定できるメリットがある。

i = Item()
__setattr__ called:stock,0
setattr(i, "stock", "100")
__setattr__ called:stock,100
getattr(i, "name", "DEFAULTNAME")  # 呼び出し時点では存在しないが__getattr__がサポートしている属性
__getattribute__ called:name
__getattr__ called:name
__setattr__ called:name,Void
__getattribute__ called:name
'Void'
getattr(i, "price", 10000)         # 存在しない、__getattr__もサポートしていない属性で、デフォルト値の指定あり
__getattribute__ called:price
__getattr__ called:price
10000
getattr(i, "id")                   # 存在しない、__getattr__もサポートしていない属性で、デフォルト値の指定なし
__getattribute__ called:id
__getattr__ called:id
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 27, in __getattr__
__getattribute__ called:__dict__
__getattribute__ called:__class__
AttributeError: Unsupported attribute:id
Python

遅延評価

__getattribute__などを利用して属性の遅延評価が行える。遅延評価を簡単に言うなら、属性の値が必要になってから属性値を計算することで、インスタンス生成時にかかる時間・処理コストや、値を保持するために必要なメモリなどを抑えるというものである。

たとえば初期化に時間がかかるアトリビュート:nameがあるとしよう。普通の実装では、nameを使用するかによらずインスタンスを生成するときにコンストラクタでnameを初期化するために時間がかかる。以下のプログラムでは擬似的にtime.sleepで時間がかかるようにしている。

import time

class SlowItem(object):
    def __init__(self) -> None:
        time.sleep(3) # nameの計算に時間がかかる
        self.name = "default name"

item = SlowItem() # この処理に時間がかかる
item.name         # この処理は時間がかからない
'default name'
Python

遅延評価を行うサンプルを示す。次に示す実装では初めてnameを取得しようとしたときにnameの初期化が行われ、時間がかかる。もしnameが使われなければ時間がかかることはない。また、一度初期化が行われれば、次から時間がかかることもない。

import time

class DelayedItem(object):
    def __init__(self) -> None:
        pass

    def __getattr__(self, __name: str):
        match __name:
            case "name":
                time.sleep(3) # nameの計算に時間がかかる
                setattr(self, "name", "default name")
                return "default name"
            case _:
                raise AttributeError(f"Unsupported attribute:{__name}")

item = DelayedItem() # この処理に時間がかからない
item.name            # この処理は時間がかかる
'default name'
item.name            # この処理は時間がかからない
'default name'
Python

次にメモリ使用量へ影響する例を見てみる。10万個の数値データを持つクラスを生成してみた。

from memory_profiler import profile

class Item:
    def __init__(self) -> None:
        self.codes = [x for x in range(0,100000)]

class ItemDelayed:
    def __getattr__(self, __name: str):
        if  __name == (codes := "codes"):
            value = [x for x in range(0,100000)]
            setattr(self, codes, value)
            return value
        else:
            raise AttributeError(f"Unsupported attribute:{__name}")

@profile
def main():
    item = Item()
    item.codes
    itemDelayed = ItemDelayed()
    itemDelayed.codes

main()
Python
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
    16     45.0 MiB     45.0 MiB           1   @profile
    17                                         def main():
    18     49.6 MiB      4.6 MiB           1       item = Item()
    19     49.6 MiB      0.0 MiB           1       item.codes
    20     49.6 MiB      0.0 MiB           1       itemDelayed = ItemDelayed()
    21     53.4 MiB      3.8 MiB           1       itemDelayed.codes

これはmemory_profilerライブラリを使用して、行ごとにメモリ使用量の増減を出力したものである。Itemクラスではインスタンス生成時にメモリが増加しているのに対し、ItemDelayedクラスではcodesへアクセスが発生したときにメモリが増加していることが分かる。

デコレータ

デコレータは複数の関数またはクラスに対して共通の加工を行う機能である。

デコレータ利用の背景と動作イメージ

2つの数字を加算する関数add_numberがある。add_numberは変更することなく、この処理の開始前後で標準出力にメッセージを出力する処理を行いたい。

def add_number(int1,int2):
    return int1+int2
Python

これは内部でadd_numberを呼び出す別の関数を作成すれば簡単に実現できる。

def add_number_debug(int1,int2):
    print("add_number start")
    v = add_number(int1,int2)
    print("add_number end")
    return v
Python

このように、定型の加工を既存の関数に行いたいというケースがある。それを実現する方法の1つがデコレータである。

まずは、この「関数の開始前後で標準出力にメッセージを出力する加工」をプログラムで行ってみる。つまり「add_number関数を引数として受け取りadd_number_debug関数を返す」関数を作成する。次のmodifier関数は加工する関数を引数targetにとり、その前後に出力を追加した関数wrapper_functionを定義し、それを返している。

def add_number(int1,int2):
    return int1+int2

def modifier(target):
    def wrapper_function(int1,int2):
        print( "add_number start")
        v = target(int1,int2)
        print( "add_number end")
        return v

    return wrapper_function

add_number_debug = modifier(add_number)

add_number_debug(1,3)
add_number start
add_number end
4
Python

ここで作成したmodifierは引数が2つに固定されており、出力内容もadd_numberという関数に固定されている。それを任意の引数の個数、関数名に対応するように改善した関数を加工する関数printmethodは以下のようになる。

def printmethod(target:callable):
    def wrapper_function(*positionalargs, **keywordargs):
        print(f"{getattr(target, '__name__', 'Function')} start")
        v = target(*positionalargs, **keywordargs)
        print(f"{getattr(target, '__name__', 'Function')} end")
        return v
    
    return wrapper_function
Python

これで任意の関数に開始前後で標準出力にメッセージを出力する加工が実装できた。さて、ここで加工したい関数に対して@printmethodを付与して実行してみる。

def printmethod(target:callable):
    def wrapper_function(*positionalargs, **keywordargs):
        print(f"{getattr(target, '__name__', 'Function')} start")
        v = target(*positionalargs, **keywordargs)
        print(f"{getattr(target, '__name__', 'Function')} end")
        return v

    return wrapper_function

@printmethod
def concat_string(str1, str2):
    return str1 + str2

concat_string("str", "ing")
concat_string start
concat_string end
'string'
Python

concat_stringにprintmethodによる加工が適用されることが分かる。つまり、デコレータとはデコレーションしてくれる関数を指定するものである。

引数をとるデコレータ

動作を調整するパラメータを引数にとるデコレータも作成できる。この場合は二重のwrapper_functionを作成する。

import datetime
import time

def recordtime(tzinfo:datetime.tzinfo=datetime.timezone.utc):
    def wrapper_function(target:callable):
        def inner_function(*positionalargs, **keywordargs):
            print( f"{datetime.datetime.now(tzinfo)} start")
            v = target(*positionalargs, **keywordargs)
            time.sleep(2.5)
            print( f"{datetime.datetime.now(tzinfo)} end")
            return v
        return inner_function
    return wrapper_function

@recordtime()
def add_number1(int1,int2):
    return int1+int2

@recordtime(datetime.timezone(datetime.timedelta(hours=+9), 'JST'))
def add_number2(int1,int2):
    return int1+int2

add_number1(1,2)
2024-12-22 13:21:04.527602+00:00 start
2024-12-22 13:21:07.028624+00:00 end
3
add_number2(1,2)
2024-12-22 22:21:09.565653+09:00 start
2024-12-22 22:21:12.066474+09:00 end
3
Python

補足

まずrecordtime(datetime.timezone(datetime.timedelta(hours=+9), 'JST'))が実行される。このrecordtime関数の中身を、引数tzinfoの値を書き換えたものは以下のようになる。

def wrapper_function(target:callable):
    def inner_function(*positionalargs, **keywordargs):
        tzinfo = datetime.timezone(datetime.timedelta(hours=+9), 'JST')
        print( f"{datetime.datetime.now(tzinfo)} start")
        v = target(*positionalargs, **keywordargs)
        time.sleep(2.5)
        print( f"{datetime.datetime.now(tzinfo)} end")
        return v
    
    return inner_function
Python

これは「JSTで前後に時刻を表示するデコレータ」であることが分かる。つまりrecordtime関数はパラメータが設定済みのデコレータ用関数を返す関数といえる。こうなってしまえばあとは普通のデコレータと同じである。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です