はじめに
こんにちは。AXLBIT株式会社1の@ax-tabuです。
この記事は、AI時代の「ソフトウェア設計」再考 と題して、AIが全盛の時代に、改めて設計の大切さを見直すシリーズのその②です。
生成AIが出力するコードの良し悪しを判断するために、設計について改めて考え直し、レビューの判断軸を身につけることを目的としています。
まだその①を読んでいない方は、そちらからお読みいただけますと幸いです。
その①はこちら → AI時代の「ソフトウェア設計」再考:①いま設計を見直すべきワケ
今回は、オブジェクト指向設計について再考します。
オブジェクト指向設計とは
オブジェクト指向設計とは、その名の通り、オブジェクト (=物)を目指して設計を行うことです。とは言ってもなかなかピンときませんよね。
オブジェクト指向設計は、システムで実現しようとする業務の世界をモノやコトをプログラミング言語で表現する設計手法 のことを指します。
それでもなおピンとこないと思います。かくいう自分もそうでした。これから例を踏まえながら解説します。
※普段pythonで開発をしているため、pythonとpydanticを使用しています。
業務の世界をモノやコトで表現する
私はお酒が好きで、特にビールが大好きです。仕事の後のビール、最高ですよね・・・🍺
本気で、いつか自分のビール屋さんをオープンしたいと思ってます。
(お酒は二十歳になってから🍺)
おっと失礼、ここからは念願のビール屋さんをオープンしたつもりでオブジェクト指向設計をご説明します。しばしお付き合いください。
茶番をすっ飛ばして、真面目な話はこちらから → オブジェクト指向設計の三大要素
ついに念願のビール屋さんを開店🍺
あなたは今日から、ビールを仕入れてオンラインサイトで販売する ビール屋さん になりました。これからいろんなビールを仕入れ、お客様からは注文が入ります。この業務だけでも、商品として販売するビール と お客様からの注文 というモノやコトが登場しました。
これを、オブジェクト指向設計で表現してみると、以下のようになります。
from pydantic import BaseModel, PrivateAttr
class Beer(BaseModel):
"""
ビールを表現するオブジェクト。
"""
price: int # 便宜上、金額をintで表現しますが、値オブジェクトを使うことをオススメします
abv: int # アルコール度数 alcohol by volume を表現。
_tax: int = PrivateAttr(default=10) # 消費税率10%
class Order(BaseModel):
"""
注文を表現するオブジェクト。
"""
goods: list[Beer] # 1個以上のビールの注文も承ります!
Beer は仕入れたビールを表現するオブジェクト、Orderは注文を表現するオブジェクトです。これこそが、業務に登場するモノやコトをオブジェクトとして表現する設計手法の根幹です。
でも、ビール以外も販売したい・・・
ビール屋さんの売り上げは順調に伸びてきた!だけど日本酒や焼酎も好きだから売りたいな🍶
日本酒や焼酎もオンラインサイトで販売するために改修が必要になりました。
これまではビールを専門に売っていましたが、これからは日本酒と焼酎も売ることになります。
ビール、日本酒、焼酎、これらに共通することはなんでしょうか。そう、お酒です。
日本酒と焼酎を販売するようになったことで、あなたはビール屋さんから、酒屋さんになりました。
システムを改修しましょう。ここで、オブジェクト指向設計における継承が活きてきます。
class Liqur(BaseModel):
price: int
abv: int
_tax: int = PrivateAttr(default=10) # 消費税率10%
class Beer(Liqur):
pass
class JapaneseSake(Liqur):
"""
日本酒を表現するオブジェクト。
"""
class Shochu(Liqur):
"""
焼酎を表現するオブジェクト。焼酎は Shochu なんですね。
"""
これで、他のお酒も表現できました。
お客様からいただく注文も、ビール以外のお酒の種類に対応する必要があるので改修しましょう。
class Order(BaseModel):
items: list[Liqur]
もっと幅を広げて、おつまみなんかも売っちゃおう🦑
酒屋さんにはお酒以外にも、おつまみも売っています。うちでも販売しましょう。
酒屋さんとして、お酒やおつまみに共通することはなんでしょうか。販売する商品ですね。
class Item(BaseModel):
"""
販売する商品を表現するオブジェクト。
"""
price: int
_tax: int = PrivateAttr()
class Snack(Item):
"""
おつまみを表現するオブジェクト。
"""
_tax: int = PrivateAttr(default=8) # お菓子は軽減税率で8%です
class Liqur(Item):
abv: int
_tax: int = PrivateAttr(default=10)
class Beer(Liqur):
pass
class JapaneseSake(Liqur):
pass
class Shochu(Liqur):
pass
class Order(BaseModel):
items: list[Item]
と、こんな感じで、業務に登場するモノやコトをオブジェクトで表現していく設計です。
そして、それらに共通する概念を抜き出して整理していきます。
最初から定義していた消費税率の_taxですが、外部から消費税率を書き換えられると、計算が合わなくなってしまうので、PrivateAttrを使って外部からアクセスできないようにしています。
お客様からご注文が入った!発送準備だ📦
お客様から、ビール、日本酒、おつまみのご注文をいただきました。
beer = Beer(price=200, abv=5)
sake = JapaneseSake(price=1000, abv=15)
snack = Snack(price=150)
order = Order(items=[beer, sake, snack])
早速この注文の発送準備に取り掛かりましょう。発送にあたり、それぞれの商品を梱包する必要がありますね。
そのまま素直に書くとこうなると思います。
order = Order(items=[beer, sake, snack])
for item in order.items:
if isinstance(item, Beer):
# ビールを梱包する処理
elif isinstance(item, JapaneseSake):
# 日本酒を梱包する処理
elif isinstance(item, Shochu):
# 焼酎を梱包する処理
elif isinstance(item, Snack):
# おつまみを梱包する処理
else:
print('この商品は発送に対応していません🙅♂️')
でもこの書き方、将来販売する商品の種類が増えれば増えるほど、発送方法のif分岐を増やさないといけません。
発送する商品は全て梱包する必要があります。商品側に梱包方法を定義してみましょう。
from abc import ABC, abstractmethod
class Item(BaseModel, ABC):
price: int
_tax: int = PrivateAttr()
@abstractmethod
def packaging(self) -> None:
"""
梱包方法を定義する。
"""
pass
ここで新たな書き方が登場しました。ABCとabstractmethodは、抽象基底クラスを定義する際に使うものです。
ABCを継承して作ったクラス(今回の場合はItem)を継承したクラス(LiqurやSnack)は、必ずpackagingメソッドを持たないとエラーになってしまいます。
これを使うことで、発送する商品が梱包方法をもつというルールを強制できるのです。
では、それぞれの商品側に梱包方法を定義していきましょう。
class Item(BaseModel):
price: int
_tax: int = PrivateAttr()
@abstractmethod
def packaging(self) -> None:
labeling_price(self.price * self._tax) # 税込金額をラベリング
class Snack(Item):
def packaging(self) -> None:
super().packaging() # ラベリングは必要なのでやりましょう
# 袋に入れるだけの簡易包装でよさそうです。
# 〜〜ここに簡易包装を行うメソッドを記述〜〜
class Liqur(Item):
abv: int
class Beer(Liqur):
def packaging(self) -> None:
super().packaging() # ラベリングは必要なのでやりましょう
# 缶ビールなので箱詰めしましょう。
# 〜〜ここに箱詰めを行うメソッドを記述〜〜
class JapaneseSake(Liqur):
def packaging(self) -> None:
super().packaging() # ラベリングは必要なのでやりましょう
# 瓶が割れたら大変です。プチプチで巻きましょう。
# 〜〜ここにプチプチで巻くメソッドを記述〜〜
class Shochu(Liqur):
def packaging(self) -> None:
super().packaging() # ラベリングは必要なのでやりましょう
# 瓶が割れたら大変です。プチプチで巻きましょう。
# 〜〜ここにプチプチで巻くメソッドを記述〜〜
これで、すべての発送商品が梱包方法を持ちました。先ほどの梱包処理を書き直してみましょう。
order = Order(items=[beer, sake, snack])
for item in order.items:
item.pakcaging()
あの長ったらしいif分岐がなくなりました。美しいですね。
これで取り扱う商品が増えても、発送方法のルールを書き直す必要がなくなり、変更容易性が高まりました。
オブジェクト指向設計の三大要素
茶番にお付き合いいただき、ありがとうございました。ここからは真面目な話です。
オブジェクト指向設計には、カプセル化・継承・多態性(ポリモーフィズム) の3つの大切な要素があります。
カプセル化 💊
カプセル化とは、「データ(状態)と、そのデータを操作する振る舞い(メソッド)をひとまとまりにし、外部からの不適切なアクセスや変更を制限する設計手法」です。
packaging メソッドで例を示したように、オブジェクトが持つpriceと_taxを使って税込金額をラベリングをしたのちに、それぞれに必要な梱包処理を行っています。
梱包処理を呼び出す側は、梱包方法を知らなくてもpackagingメソッドを呼び出せば、梱包が完了するのです。また、万が一梱包方法が変わった場合も、BeerやJapaneseSakeクラス側の梱包方法を変更するだけでよいので、変更が外部に漏れ出しません。
さらに、梱包方法をif文で書いていたときは、梱包処理を呼び出す側に梱包方法に関する業務知識が漏れ出していました。
商品側に梱包方法を定義することで、業務に関する知識を凝集することができるのです。
このように、変更や業務知識を閉じ込めることができるのです。
継承 ⬇️
オブジェクト指向設計における継承とは、「共通する性質や責務を抽象化し、それをより具体的な概念へ段階的に特殊化していく設計手法」です。
今回の例ではビール屋さんから始まり、酒屋さんになりました。最初に取り扱う商品はBeerだけでしたが、他の種類のお酒やおつまみも扱うようになりました。
酒屋さんで取り扱うものは金額と消費税率をもつ商品です。このように、共通する性質を抜き出して取り扱い商品Itemとして抽象化したのちに、BeerやSnackなどのより具体的な概念に実装していきました。
BeerとJapaneseSake、Snackは取り扱う商品Itemとしては同じ概念ですが、BeerとJapaneseSakeは酒類商品として共通しているので、さらにLiqurとして抽象化しました。
class Item(BaseModel):
pass
class Snack(Item):
pass
class Liqur(Item):
pass
class Beer(Liqur):
pass
class JapaneseSake(Liqur):
pass
class Shochu(Liqur):
pass
多態性(ポリモーフィズム) 🎭
多態性(ポリモーフィズム) は、「同じ操作を呼び出しても、対象のオブジェクトによって振る舞いが変わる性質」のことです。
こちらもpackagingメソッドが該当します。
注文が入って梱包するときに、商品ごとにpackagingメソッドを呼び出すだけで梱包が完了します。梱包処理側はその商品がなんなのかを一切気にすることなく梱包できます。
order = Order(items=[beer, sake, snack])
for item in order.items:
item.pakcaging()
おわりに
ここまでお読みいただきありがとうございます。
オブジェクト指向設計について例(茶番)を交えながらご説明しました。
プロンプトを工夫せず単純に生成AIにコードを書かせると、if分岐をバンバン使って、変更容易性も可読性も下がってしまいます。
オブジェクト指向設計を知っていれば、プロンプトを工夫したり、AIが生成してきたコードの良し悪しを判断できます。
変更が辛くないコードを目指して、もっと設計について学んでいきましょう!