その①では、オブジェクト指向とは何なのか?という話と、実際にサイコロを使ってオブジェクト指向プログラミングの基本を学習しました。
しかし、その①のようなクラスの設計ではオブジェクト指向プログラミングの原則に反しているというところで終わりました。
ここではその原則に則った設計ができるようになるために「カプセル化」と「継承」について解説していきます。
オブジェクト指向プログラミングの原則1[カプセル化]
カプセル化とは「データと動作を一つのクラスで覆い、外部アクセスから内部のデータを保護する」ことを指します。
ここで言うデータとは「状態」のことで、外部アクセスからデータを保護するとは「状態をインスタンスから直接参照できないようにする」ことです。
つまり、先ほどインスタンス変数から直接number変数にアクセスできてしまう状態は、カプセル化という点からは不十分だった、ということになります。
ではなぜ外部からデータを守る必要があるのでしょうか?
それは「外部からデータを不正に変更されることがないようにするため」です。
例えば今回設計したDiceクラスは、現在のサイの目を保持するためにnumber変数を設けていますが、これがdice.roll()を介さず勝手に数値をいじれてしまうとどうなるでしょうか?
[main.py]
count = 0
for i in range(3):
dice.number = 10 #サイの目を直接10に変更!
print(f"{i+1}回目:{dice.number}")
count += dice.number
print(f"合計:{count}")
[出力結果]
1回目:10
2回目:10
3回目:10
合計:30
6までしか出ないサイコロなのに、10が出るのは明らかにおかしいですよね。
こういった不正ができないようにするために、dice.roll()以外からはnumber変数を変えられないようにしたいところです。
そのためにカプセル化が重要になります。
では、カプセル化した後のソースを見てみましょう。
[dice.py]
import random
class Dice:
__number = 1
def number(self):
return self.__number
def roll(self):
self.__number = random.randint(1, 6)
[main.py(変更点のみ)]
for i in range(3):
dice.roll()
print(f"{i+1}回目:{dice.number()}")
count += dice.number()
dice.pyから変わった点を見ていきましょう。
まずはnumber変数の接頭辞にアンダーバーを2つ付けました。
アンダーバーを2つ付けることで、外部から直接number変数にアクセスできないようになります(後で試してみます)。
ただ、これではnumberを取得することもできなくなってしまうので、numberを取得するためのnumberメソッドを追加しました。
今後はnumber()を介して__number変数の値を取得します。
次にmain.pyを見ていきましょう。
dice.numberへアクセスする際、dice.numberではなくdice.number()に変え、numberメソッドを通してクラス変数にアクセスするように変更しました。
これで直接number変数を変更することなく、かつ値の取得だけできるようになりました。
実際に直接number変数を変更しようとしてもエラーにはなりませんが、何度やっても全て1になります。
count = 0
for i in range(3):
dice.__number = 6 #直接6に変更?
print(f"{i+1}回目:{dice.number()}")
count += dice.number()
print(f"合計:{count}")
[出力結果]
1回目:1
2回目:1
3回目:1
合計:3
これは__number変数に直接6を入れようとしても実際は入っていないから、デフォルト値の1が出力されています。
補足:非推奨ではありますが、こうすることで__number変数を直接変更することができます。
dice._Dice__number = 6
実はアンダーバー2つを変数名の前に付けるのは「ネームマングリング機構」というもので、変数名の前に「_class名」が付いてアクセスし難くする意図があります。
なので、実際には__をつけたら外部からアクセスできなくなるわけではなく、アクセスされづらくする、または非推奨であることを伝える意図を残せる程度のものということになります。
@propertyの活用
さて、ここでは__number変数にアクセスするためにdice.number()を使いましたが、これをもう少しスマートにしていきましょう。
[dice.py(変更点のみ)]
@property
def number(self):
return self.__number
[main.py(変更点のみ)]
print(f"{i+1}回目:{dice.number}")
count += dice.number
クラス内のnumber()の上に@propertyを付与することで、dice.number()でメソッドを呼び出す際に()が不要になりました。
こうすることでメソッドを呼び出すイメージが薄れ、直接データを参照できているように外部から見せることができます。
セッターの定義
さて、先ほどは不正ができないようにdice.roll()以外からは出目を変えられないようにしましたが、どうしても最初の出目が1は嫌だ!という場合があったらどうしましょうか?
そういったときはセッターが必要になります。セッターとは「クラス内のデータを変更できるメソッド」のことです(ちなみにクラス内のデータを参照できるメソッドのことを「ゲッター」と呼びます)。
[dice.py(追加分のみ)]
@number.setter
def number(self, value):
if isinstance(value, int) and 1 <= value <= 6:
self.__number = value
else:
raise ValueError("数値は1〜6の整数で設定してください。")
[main.py]
from dice import Dice
dice = Dice()
print(f"変更前:{dice.number}")
dice.number = 5
print(f"変更後:{dice.number}")
try:
dice.number = 7
except ValueError as e:
print(e)
[出力結果]
変更前:1
変更後:5
数値は1〜6の整数で設定してください。
セッターを作る上で重要なのが「1〜6以内の整数のみ受け付ける」状態にすることです。
今回は1〜6以外の値がきたら例外を出すようにしています。
では順に見ていきましょう。
セッターメソッドを定義する場合は、@プロパティ名.setterをメソッドの上に付ける必要があります。プロパティ名は先ほど@propertyを付けたメソッドのメソッド名なので、ここでは@number.setterになります。
メソッド内ではisinstanceを使ってint型であることを確認し、かつ1〜6の数値のみ受け付けるようにしています。それ以外の数値がきた場合はValueErrorを投げます。
これでサイの目を変えることができるようになりました。number変数を直接いじれると10などの不正な数値を入れられてしまいますが、セッターを介することで設定できる値を制限できました。
なお、セッターメソッドは2つの引数(selfとvalue)を取るメソッドに見えますが、1つ目のselfは自身が所属するクラスを指すもので、引数ではありません。
なので、2つ目のvalue分のみ指定してあげればOKです。
__init__でnumber変数を初期化する
セッターを設けたことでサイの目を自分で選べるようになりましたが、依然としてサイの目の初期値は1から選べないままです。
今の状態だと初期値を変更するには
- Dice()でインスタンスを生成
- セッターで__number変数を変更
の2つのステップを踏む必要があります。 それだと少し面倒なので、Dice()でインスタンスを生成するタイミングで__number変数の初期値を設定できるようにしてみましょう。
[dice.py(変更点のみ)]
def __init__(self, number = 1):
self.number = number
(main.py)
from dice import Dice
dice1 = Dice(3)
print(f"初期値設定時:{dice1.number}")
dice2 = Dice()
print(f"初期値設定なし:{dice2.number}")
try:
dice3 = Dice(7)
except ValueError as e:
print(e)
[出力結果]
初期値設定時:3
初期値設定なし:1
数値は1〜6の整数で設定してください。
Diceクラスの__number = 1の部分を削除して、__init__というメソッドを新たに設けました。
__init__メソッドはインスタンスを生成するときに最初に呼ばれるメソッドで「初期化メソッド」と呼びます。
初期化メソッドはnumber引数を取り、self.numberに渡しています。自身のnumberメソッドに値を渡しているので、これはセッターメソッドを呼び出し、__numberを初期化しています。
初期化メソッドを使うにはインスタンス生成時に引数を渡すだけです。初期値設定がない場合はデフォルト値が1になっているので1が出力されます。 また、先ほど作成したセッターメソッドを通しているので、1〜6の整数以外の引数を設定した場合はValueErrorになっているのが分かります。
オブジェクト指向プログラミングの原則2[継承]
最後にオブジェクト指向プログラミングで最も重要な「継承」について解説します。
継承とは「元あるクラスに追加の状態や動作を上乗せする」機能です。
親クラスとは継承される側のクラスのことで、子クラスは新たに設計される継承する側のクラスのことです。
継承を利用することで、元々設計していたクラスに新しい機能を加えたニュークラスを作ることができます。
なぜこんなことをする必要があるのでしょうか?おそらくこの記事を読んだ方の中には「親クラスを継承して子クラスを作るより、親クラスに機能を追加して子クラス相当のクラスを作った方が分かりやすい」と思う方もいるのではないでしょうか?
実際、そういったパターンの場合もあります。特に小規模で簡単なシステムを作る場合は、継承を使うよりそのままクラスに機能を追加した方が良いパターンの方が多いでしょう。
しかし、それなりに大きなシステムになってくると話は違います。というのも、複数のクラスが同じような機能を有している場合があるからです。
その場合、別々のクラスで同じ機能が重複して発生し、冗長であまり美しくないプログラムになってしまいます。
この冗長な部分を親クラスに委ね、異なる状態や動作の部分のみを子クラスに設計することで、冗長じゃない美しいプログラムが出来上がります。
では、この継承機能をサイコロで考えてみましょう。
先ほどまで設計していたサイコロは六面体で、1〜6までの数字が出るサイコロでした。
しかし、サイコロと一口に言ってもその様相は様々です。色や面の数、材質、出目の点の形などなど。
なので、ここでは継承をつかって、サイコロの色の情報を追加してみたいと思います。
dice.pyは修正せず、新たにcolor_dice.pyファイルを作成します。
[color_dice.py]
from dice import Dice
class ColorDice(Dice):
def __init__(self, number = 1, color = '白'):
super().__init__(number=number)
self.__color = color
@property
def color(self):
return self.__color
[main.py]
from color_dice import ColorDice
red_dice = ColorDice(color='赤')
blue_dice = ColorDice(color='青')
red_dice.roll()
blue_dice.roll()
print(f"{red_dice.color}色のサイコロは:{red_dice.number}")
print(f"{blue_dice.color}色のサイコロは:{blue_dice.number}")
[出力結果]
赤色のサイコロは:6
青色のサイコロは:5
color_dice.pyでColorDiceクラスを設計しています。
Diceクラスと違うところはColorDiceの後にかっこをつけて、その中にDiceを渡している点です。
こうすることでDiceクラスを継承することができ、ColorDiceのインスタンスからDiceの動作や状態にアクセスできるようになります。
次に初期化メソッドではnumberの他にcolorという引数も用意しています。これがサイコロの色を決める引数です。
super()というのは親クラスを指すメソッドで、ここではDiceクラスを指しています。なのでsuper().__init__はDiceクラスの初期化メソッドを呼び出し、numberに引数を渡しています。
Diceクラスの初期化メソッド内でnumberのsetterを呼び出しているので、ColorDiceを使う場合も1〜6の数字以外は受け付けません。
あとはcolor用のゲッターも用意しておきましょう。
次にmain.py側です。
ColorDiceをインポートしてそれぞれ赤いサイコロと青いサイコロのインスタンスを生成しています。
その後dice.roll()を呼んでいます。ColorDiceではrollメソッドは作っていませんが、roll()があるDiceを継承しているのでColorDiceのインスタンスでもroll()を使えます。