前書き
前回に引き続き、Effective Python復習回です。
今回は属性のお話。クラスを扱う上でほぼ確実に使用する属性ですが、これまではあまり色々考えずに設定していました。なのでこの機会に公式ドキュメント等も交えて、あらためて学習していきたいと思います。
結論から先に述べますと、
- クラスの属性は基本的にシンプルかつパブリックなものに設定する
- 必要であれば@propertyを併用する。その際には思わぬ挙動を起こさないように注意
となります。
属性の隠蔽
これまで私は、とりあえず、なんとなくアンダースコアをくっつけてコードを書いていました。具体的な例に入る前に、まずはこのアンダースコアをくっつけることの意味について確認していきます。
このアンダースコアを使った表記は、コーディングスタイルの指標であるPEP8に以下のように使用方法が記載されています。
_single_leading_underscore: weak “internal use” indicator. E.g. from M import * does not import objects whose names start with an underscore.
先頭に単一のアンダースコアをつけることは、弱い"内部のみでの使用"を示唆するものです。たとえば、from M import *の形式でのインポートではアンダースコアから始まるオブジェクトはインポートされません。
アンダースコア1つの場合
実際に試してみましょう。同階層にimportされる側のimported.pyとする側のimporting.pyをおいて動かしてみます。
class PublicClass:
def __init__(self):
self.public_number = 123
self._private_number = 345
print("I am PublicClass.")
class _PrivateClass:
def __init__(self):
self.public_number = 123
self._private_number = 345
print("I am _PrivateClass.")
from imported import *
public_class = PublicClass()
print(f"public_number: {public_class.public_number}")
print(f"private_number: {public_class._private_number}")
private_class = _PrivateClass()
print(f"public_number: {private_class.public_number}")
print(f"private_number: {private_class._private_number}")
これを動かすと以下のようなエラーを吐きます。
I am PublicClass.
public_number: 123
private_number: 345
Traceback (most recent call last):
File "importing.py", line 6, in <module>
private_class = _PrivateClass()
NameError: name '_PrivateClass' is not defined
文章で言われている通り、_PrivateClassの方はimportされていないようですね。
ただし、これはあくまでワイルドカードでimport先を指定したときの話で、きちんと名前まで指定すると普通に動作します。つまり、importing.pyの一行目を
from imported import *
から、
from imported import PublicClass, _PrivateClass
に変更するということです。このとき、出力は以下のようになります。
I am PublicClass.
public_number: 123
private_number: 345
I am _PrivateClass.
public_number: 123
private_number: 345
あくまで"弱い"内部使用の示唆であり、システム的に読み込みを阻害するようなものはない。読んだ人に「外部からアクセスしないでね」とお願いする程度の制限のようです。
アンダースコア2つの場合
次にアンダースコアふたつの表記ですが、こちらは以下のように規定されています。
__double_leading_underscore: when naming a class attribute, invokes name mangling (inside class FooBar, __boo becomes _FooBar__boo; see below).
クラスの属性名の先頭にアンダースコアを2つつけると、名前のマングリング
(難号化)を引き起こします。たとえばFooBarクラスで__booと名付けた属性の名前は、_FooBar__booとなります。
Generally, double leading underscores should be used only to avoid name conflicts with attributes in classes designed to be subclassed.
一般的に、先頭にアンダースコアを2つ付けるやり方は、サブクラスとして作られたクラスで名前の衝突を避けるという目的でのみ用いられるべきです。
こちらも実際に動かしてみましょう。まずは単純にマングリングさせます。
class Dog:
def __init__(self, name):
self.__name = name
dog = Dog("John")
# print(sample.__name) -> AttributeError: 'Sample' object has no attribute '__name'
print(dog._Dog__name)
John
通常のやり方ではAttributeErrorが挙上するため、_(クラス名)__(属性名)で呼び出さなければなりません。
次に、親クラスの属性と同じものをサブクラスにも用意し、両者を読み込んでみます。
class ParentDog:
def __init__(self, name):
self.name = name
class ChildDog(ParentDog):
def __init__(self, parent_name, name):
super().__init__(parent_name)
self.__name = name
child_dog = ChildDog("John", "Dave")
print(child_dog.name)
print(child_dog._ChildDog__name)
John
Dave
ただ、このやり方は正直しっくりこないというか、難号化が必要なケースがあまり思い浮かびません。親の属性が欲しいのであれば、インスタンスとして引き継げば良いのでは?と感じてしまいます。
class Dog:
def __init__(self, name, parent=None):
self.parent = parent
self.name = name
def __str__(self):
return self.name
john = Dog("John")
dave = Dog("Dave", john)
for dog in [john, dave]:
print(f"{dog}'s parent is {dog.parent}")
John's parent is None
Dave's parent is John
これもひとえに経験不足のせいでしょうね。何か適切な例があるのであれば教えていただきたいところです。
propertyの利用
前振りも終わったところで、@propertyを活用した実装について考えていきます。
ここでは例としてRPGで登場人物やモンスターの設定を構築することを考えます。キャラクターには名前、HP、攻撃力、防御力の4つのステータスがあるとしましょう。攻撃を行うと、攻撃力から防御力を引いた分だけのダメージを相手のHPに与えることができます。伝統と信頼のアルテリオス計算式ですね。
getter, setterメソッドを用いた場合
他言語ではわりとよく見られるというgetter,setterを使った実装から行っていきます。
class Character:
def __init__(self, name, hitpoint, offensive_power=0, defensive_power=0):
self._name = name
self._hitpoint = hitpoint
self._offensive_power = offensive_power
self._defensive_power = defensive_power
def get_hitpoint(self):
return self._hitpoint
def set_hitpoint(self, hitpoint):
self._hitpoint = hitpoint
def get_offensive_power(self):
return self._offensive_power
def set_offensive_power(self, offensive_power):
self._offensive_power = offensive_power
def get_defensive_power(self):
return self._defensive_power
def set_defensive_power(self, defensive_power):
self._defensive_power = defensive_power
John = Character("John", 20, offensive_power=10, defensive_power=5)
Kate = Character("Kate", 20, offensive_power=8, defensive_power=7)
print(f"John's hitpoint: {John.get_hitpoint()}")
print(f"Kate's hitpoint: {Kate.get_hitpoint()}")
print("John's attack!")
damage = John.get_offensive_power() - Kate.get_defensive_power()
print(f"{damage} Damage!")
Kate.set_hitpoint(Kate.get_hitpoint() - damage)
print(f"John's hitpoint: {John.get_hitpoint()}")
print(f"Kate's hitpoint: {Kate.get_hitpoint()}")
John's hitpoint: 20
Kate's hitpoint: 20
John's attack!
3 Damage!
John's hitpoint: 20
Kate's hitpoint: 17
非常にシンプルかつスタンダードな戦闘処理ですが、バチバチにクドいですね。自分で書いてて目が滑ります。ゲームはまだ作ったことありませんが、基本処理がこんな調子だったら発狂する自信があります。
平易な属性名を用いた場合
これに対し、Effective Pythonでは、「そもそも平易な名前で属性を定義する」ことを提唱しています。書き換えたプログラムは以下のようになります。
class Character:
def __init__(self, name, hitpoint, offensive_power=0, defensive_power=0):
self.name = name
self.hitpoint = hitpoint
self.offensive_power = offensive_power
self.defensive_power = defensive_power
John = Character("John", 20, offensive_power=10, defensive_power=5)
Kate = Character("Kate", 20, offensive_power=8, defensive_power=7)
print(f"John's hitpoint: {John.hitpoint}")
print(f"Kate's hitpoint: {Kate.hitpoint}")
print("John's attack!")
damage = John.offensive_power - Kate.defensive_power
print(f"{damage} Damage!")
Kate.hitpoint -= new_kate_hitpoint
print(f"John's hitpoint: {John.hitpoint}")
print(f"Kate's hitpoint: {Kate.hitpoint}")
出力はおなじであるため省きますが、だいぶマシになりました。結局のところ強固なプライベート属性が設定できない以上、可読性を大きく損ねてまでアンダースコアを用いた表記をするべきではないということがわかります。
propertyの活用
ところで、この処理にはいろいろと問題があります。たとえば、HPが負になりうるというのもその一つです。
class Character:
def __init__(self, name, hitpoint, offensive_power=0, defensive_power=0):
self.name = name
self.hitpoint = hitpoint
self.offensive_power = offensive_power
self.defensive_power = defensive_power
John = Character("John", 20, offensive_power=10, defensive_power=5)
Tom = Character("Tom", 5, offensive_power = 5, defensive_power=1)
print(f"John's hitpoint: {John.hitpoint}")
print(f"Tom's hitpoint: {Tom.hitpoint}")
print("John's attack!")
damage = John.offensive_power - Tom.defensive_power
print(f"{damage} Damage!")
Tom.hitpoint -= damage
print(f"John's hitpoint: {John.hitpoint}")
print(f"Tom's hitpoint: {Tom.hitpoint}")
John's hitpoint: 20
Tom's hitpoint: 5
John's attack!
9 Damage!
John's hitpoint: 20
Tom's hitpoint: -4
HPは基本的に0=死亡、あるいは戦闘不能となる数値であり、負の値になるべきではありません。つまり、ヒットポイントが0より小さくなるときは0になるようにする必要があります。
setterで値を制限
Effective Pythonではこのように属性に特別な振る舞いをさせたいときには、propertyを併用することを推奨しています。具体的には以下のような感じです。
class Character:
def __init__(self, name, hitpoint, offensive_power=0, defensive_power=0):
self.name = name
self._hitpoint = hitpoint
self.offensive_power = offensive_power
self.defensive_power = defensive_power
@property
def hitpoint(self):
return self._hitpoint
@hitpoint.setter
def hitpoint(self, hitpoint):
if hitpoint < 0:
hitpoint = 0
self._hitpoint = hitpoint
John = Character("John", 20, offensive_power=10, defensive_power=5)
Tom = Character("Tom", 5, offensive_power = 5, defensive_power=1)
print(f"John's hitpoint: {John.hitpoint}")
print(f"Tom's hitpoint: {Tom.hitpoint}")
print("John's attack!")
damage = John.offensive_power - Tom.defensive_power
print(f"{damage} Damage!")
Tom.hitpoint -= damage
print(f"John's hitpoint: {John.hitpoint}")
print(f"Tom's hitpoint: {Tom.hitpoint}")
John's hitpoint: 20
Tom's hitpoint: 5
John's attack!
9 Damage!
John's hitpoint: 20
Tom's hitpoint: 0
hitpoint.setterで新たなHPが0を下回る場合は強制的に0にすることで、負のHPという意味不明な状態を回避することができます。
setterで属性の値の変更を禁止
HPや攻撃力防御力は、戦闘や装備変更レベルアップ等で変わる可能性がある属性ですが、一方でたとえば種族のような生成時から変更すべきではない属性もあります。これもsetterで実現可能です。
class Character:
def __init__(self, name, species):
self.name = name
self._species = species
@property
def species(self):
return self._species
@species.setter
def species(self, species):
if hasattr(self, '_species'):
raise AttributeError("Species is immutable.")
self._species = species
John = Character("John", "TV")
John.species = "PC"
Traceback
...
AttributeError: Species is immutable.
このように、すでに種族属性を持っているときに外部から変更を加えようとすると、AttributeErrorが挙上するようにすることで変更を防ぐことが可能です。
なお、私は最初にこのパートを見たときに、「ifとかまどろっこしいことをしないで、species.setterにアクセスされたときに速攻でraiseすればよくない?」と思いましたが、これはインスタンス作成時にかぎらず、「一度設定されたら変更しない」という意図のもと書かれたコードのようです。試しにこれを省くと、
class Character:
def __init__(self, name, species=None):
self.name = name
if species is not None:
self._species = species
@property
def species(self):
return self._species
@species.setter
def species(self, species):
raise AttributeError("Species is immutable.")
John = Character("John")
John.species = "TV"
Traceback
...
AttributeError: Species is immutable.
このように後からの設定ができなくなってしまいます。
propertyの副作用を避ける
以上のようにpropertyを使用することで、属性への制約と可読性の向上を同時に実現させることができます。ただ、何でもかんでもpropertyにおまかせすると、思わぬ挙動でビックリさせられるの注意深く使う必要があります。
Effective Pythonではその例として、一つのpropertyメソッドの中で他の属性の値を変更することを推奨していないという旨の内容が記載されています。ここでは力と敏捷性の積が攻撃力になるという少しだけ凝った設定にして動作させてみます。
class Character:
def __init__(self, strength, agility):
self.strength = strength
self.offensive_power = strength * agility
self._agility = agility
@property
def agility(self):
self.offensive_power = self._agility * self.strength
return self._agility
@agility.setter
def agility(self, agility):
self._agility = agility
John = Character(10, 2)
print(f"John's offensive_power: {John.offensive_power}")
John.strength = 20
print(f"John's offensive_power: {John.offensive_power}")
John.agility
print(f"John's offensive_power: {John.offensive_power}")
John's offensive_power: 20
John's offensive_power: 20
John's offensive_power: 40
strengthを20にした段階では攻撃力は20のままなのに、あとでagilityを書くだけ書いたときに40になっています。
妙な動作をさせないためには、そもそもpropertyで属性の値の変更を行うべきではありません。propertyはあくまで値を返すためのものであり、ある属性の値の変更に伴う処理はsetterに記入すべきです。
strengthへのpropertyの実装も含めて、以下のようなコードであればひとまずおかしな動作はしなくなります。
class Character:
def __init__(self, strength, agility):
self._strength = strength
self.offensive_power = strength * agility
self._agility = agility
@property
def agility(self):
return self._agility
@agility.setter
def agility(self, agility):
self._agility = agility
self.offensive_power = self.strength * self._agility
@property
def strength(self):
return self._strength
@strength.setter
def strength(self, strength):
self._strength = strength
self.offensive_power = self._strength * self.agility
John = Character(10, 2)
print(f"John's offensive_power: {John.offensive_power}")
John.strength = 20
print(f"John's offensive_power: {John.offensive_power}")
John.agility
print(f"John's offensive_power: {John.offensive_power}")
John's offensive_power: 20
John's offensive_power: 40
John's offensive_power: 40
もちろんここに、最初にHPに行ったように、「ステータスは0を下回らない」という設定を付け加えてもいいでしょう。
class Character:
def __init__(self, strength, agility):
self._strength = strength
self.offensive_power = strength * agility
self._agility = agility
@property
def agility(self):
return self._agility
@agility.setter
def agility(self, agility):
if agility < 0:
agility = 0
self._agility = agility
self.offensive_power = self.strength * self._agility
@property
def strength(self):
return self._strength
@strength.setter
def strength(self, strength):
if strength < 0:
strength = 0
self._strength = strength
self.offensive_power = self._strength * self.agility
John = Character(10, 2)
Kate = Character(20, 5)
print("Enemey's debuff!")
John.strength -= 50
Kate.agility -= 50
print(f"John's offensive_power: {John.offensive_power}")
print(f"Kate's offensive_power: {Kate.offensive_power}")
Enemey's debuff!
John's offensive_power: 0
Kate's offensive_power: 0
まとめ
propertyの本質は、属性に関する処理をコンパクトにすることなのだと思います。考えてみれば一々戦闘処理するたびにif damage > John.hp...のような処理をしていては物凄く読みづらいコードになることは想像に難くありません。今後は必要に応じてpropertyとsetterを併用して、可読性を向上させつつコードを書いていきたいと思います。
ご覧いただきありがとうございました。次回もよろしくお願いします。
参照サイト
参考書籍
- Slatkin Brett "Effective Python: 90 Specific Ways to Write Better Python (Effective Software Development Series) (English Edition) 2nd 版, Kindle版" Addison-Wesley Professional 2019/10/25 p.181 ~ p.185