クロージャはオブジェクト指向の構成要素である
クラスじゃなくてもオブジェクト指向を実現できる!?
ITエンジニア本大賞2024年の技術書部門ベスト10であるちょうぜつソフトウェア設計入門
8章デザインパターンで高階関数でもオブジェクト指向の構成要素になる話がありました。
今回は、高階関数が本当にオブジェクト指向の構成要素となるのか検証してみました。
高階関数・クロージャとは
文中から引用
高階関数とは、整数や文字列といった値だけでなく、関数を引数や返り値として扱える関数です
クロージャ
高階関数の中でも、記述された文脈にある変数を束縛できる特徴を持ったものをクロージャと呼びます。
…これだけだと分かりにくいですね。実装例を次に示します。
クロージャ実装例
クロージャの例として分かりやすいのはカウンターだと思います。
from collections.abc import Callable
def counter(count: int) -> Callable[[], int]:
i = count
def increment() -> int:
nonlocal i
i = i + 1
return i
return increment
my_counter = counter(0)
print(my_counter()) # 1
print(my_counter()) # 2
print(my_counter()) # 3
my_counter2 = counter(10)
print(my_counter2()) # 11
print(my_counter2()) # 12
print(my_counter2()) # 13
counter()
は中にincrement()
の内部関数を持った関数です。
counter()
を呼び出したら内部変数i
で保持し、返却値として関数increment()
を返却します。
関数を返却値としているので高階関数と呼べそうですね。
my_counter
はincrement()
関数を受け取っています。
使用すると、increment()
が呼び出されi
に1を足した値を返却します。
変数を束縛するというのは、関数内に変数スコープを閉じ込めることであるといえそうです。
my_counter2
のように新たな関数を定義すれば、my_counter
とmy_counter2
は別々の関数として動きます。
詳しい説明はクロージャって何のためにあるん?が分かりやすいです。
なぜオブジェクト指向の構成要素になる?
クロージャの動作は大体理解しました。
では、これがなぜオブジェクト指向の構成要素となるでしょうか?
本書同節(8-8)でオブジェクト指向の構成要素をこう語られています
「振る舞いの詳細を隠蔽した抽象」であるかぎり、それはまぎれもなくオブジェクト指向の構成要素です。
これを見て私はこう思いました。
「クロージャって思いっきり内部実装書いてあるけど? 何で構成要素になり得るのだ…?」
と思ったので、本書のサンプルを使い
クラスを使った振る舞いとクロージャを使った振る舞いを比較したいと思います。
使用サンプルは本書8-4で紹介されているAbstract Factoryパターンです。
クラスを使った実装例
本書8-4で紹介されていた、ペット購入処理について考えていきます。
ペット購入の流れをAbstract Factoryパターンで実現するとこうなります。
PetShopInterface
がAbstract Factoryパターンの肝にあたります。
from abc import ABC, abstractmethod
class Pet(ABC):
@abstractmethod
def echo(self) -> None:
pass
class Dog(Pet):
def echo(self) -> None:
print("dog")
class Cat(Pet):
def echo(self) -> None:
print("cat")
# Pet生成を受け持つインターフェース
class PetShopInterface(ABC):
@abstractmethod
def create_pet(self, pet_type: str) -> Pet:
pass
# Pet生成の具体的処理を記述したクラス
class PetShop(PetShopInterface):
def create_pet(self, pet_type: str) -> Pet:
match pet_type:
case "dog":
return Dog()
case "cat":
return Cat()
case _:
raise Exception
class PetBuyer:
# pet_shopはPetShopInterfaceを満たしているものであれば何でも良い
def buy_pet(self, pet_shop: PetShopInterface, pet_type: str) -> None:
pet = pet_shop.create_pet(pet_type)
pet.echo()
pet_buyer = PetBuyer()
shop = PetShop()
pet_buyer.buy_pet(shop, "dog")
pet_buyer.buy_pet(shop, "cat")
関係性をUMLで表現した図はこちら
Pet生成クラスにも依存性逆転が発生している
Abstract Factoryパターンのポイントはインスタンス生成クラスも
依存性逆転が起きて不安定が安定に依存する形が出来ているところです。
PetBuyer
はPet
とPetShopInterface
の使用クラスです。
使用クラスは具体的処理が記述されているため、仕様変更の影響を受けやすい不安定なクラスといえます。
Pet・PetInterface
は振る舞いだけ定義されており、内部実装はありません。
仕様変更による影響を受けにくい安定しているクラスといえます。
PetBuyer
はPetInterface
の振る舞いを実装しているクラスなら何でも受け取れます。
それがモックだろうが、Catしか返却できないCatOnlyPetShop
だろうが関係ありません。
このことから 「PetInterfaceは振る舞いの詳細を隠蔽した抽象」 と呼べそうです。
これを踏まえてペット生成処理をクロージャの実装に置き換えます。
クロージャがペット生成処理を抽象化して扱えるのであれば高階関数はオブジェクト指向の構成要素といえそうです。
クロージャを使った実装例
Abstract Factoryパターンをクロージャを使って実装してみます。
from abc import ABC, abstractmethod
from collections.abc import Callable
class Pet(ABC):
@abstractmethod
def echo(self) -> None:
pass
class Dog(Pet):
def echo(self) -> None:
print("dog")
class Cat(Pet):
def echo(self) -> None:
print("cat")
# Pet生成を受け持つ高階関数
def shop() -> Callable[[str], Pet]:
def create_pet(pet_type: str) -> Pet:
match pet_type:
case "dog":
return Dog()
case "cat":
return Cat()
case _:
raise Exception
return create_pet
class PetBuyer:
def buy_pet(self, pet_shop: Callable[[str], Pet], pet_type: str) -> None:
pet = pet_shop(pet_type)
pet.echo()
pet_buyer = PetBuyer()
pet_buyer.buy_pet(shop(), "dog")
pet_buyer.buy_pet(shop(), "cat")
先程のPetInterface
がshop()
に置き換えられました。
shop()
は内部関数にcreate_pet()
を受け持ち、種別を受け取りペットを生成して返却します。
一見、Abstract Factoryパターンを模倣出来ていないのではと思います。
クロージャに内部実装が書かれているため、不安定なPetBuyer
が不安定なクロージャに依存していそうです。
しかし、PetBuyer
をよく見ると、しっかり抽象を扱えているのが分かります。
関係性のUMLを確認してください。クラスのUMLと似ていることが分かります。
PetBuyerは抽象(Callable)に依存している…!?
PetBuyer
が依存しているのはPet
と Callable
であることが分かります。
Callableはcollection.abc.Callable
であり、__call__
メソッドの振る舞いだけが定義された抽象基底クラスです。
内部関数のcreate_pet()
がCallableにあたります。
Callable[[str], Pet]
は引数にstr型を受け取り、Pet型を返却する関数型を意味します。
つまり、PetBuyerは Callable[[str], Pet]
を実装したものであれば何でも良いことになります。
その中身がモックでも構いません。以下のような猫しか返却しなくても問題ありません。
def cat_only_shop() -> Callable[[str], Pet]:
def create_pet(pet_type: str) -> Pet:
if pet_type == "cat":
return Cat()
raise Exception
return create_pet
このことから、PetBuyerは安定に依存していると言えます。
「クロージャも振る舞いの詳細を隠蔽した抽象」 と呼べそうです。
オブジェクト指向の構成要素となり得そうだということが分かりました。
高階関数は単一責務をもつ設計といえる
クロージャ、よく見るとクラスっぽく見えます。
最初の例であげた、カウンターを再度見てみましょう。
from collections.abc import Callable
def counter(count: int) -> Callable[[], int]:
i = count
def increment() -> int:
nonlocal i
i = i + 1
return i
return increment
これをクラスで表すならこうでしょうか
class Counter:
def __init__(self, count: int) -> None:
self.i = count
def increment(self) -> int:
self.i = self.i + 1
return self.i
ポイントは、クロージャは内部で変数を保持しつつ、内部の関数で振る舞いを実現することです。
変数を保持・振る舞いを持つ。これってクラスの定義と似ていますね。
このクロージャは内部で一つだけ関数を保持しています。
メソッドを持ち過ぎない単一の責務を持っている。
単一責任原則(SRP)の原則に合ったものでもあるといえます。
まとめ
今回の検証をまとめると
「高階関数で扱う関数は程よく抽象化された型であり、振る舞いの詳細を隠蔽した抽象である。つまりオブジェクト指向の構成要素といえる」
といったところでしょうか。
以下感想です。
「オブジェクト指向はクラスじゃなくても書ける」
というのは言葉として知っていましたが、実際にどう実現するかは知りませんでした。
今回の検証を通じて、関数を使ったオブジェクト指向の実現方法が分かり
設計の武器が一つ増えたと感じています。
クロージャについても、概念が出てきたのはクラスとほぼ同時期というのが面白く
ここらへんの歴史も深堀って調査してみたいと思います。
検証はPythonを使用しました。
他の言語でも高階関数をサポートしているなら同様の実装が可能だと思います。
ぜひ、皆さんの手元で検証してみて実感していただければ幸いです
Discussion