オブジェクトシステムを作ろう!!
ある日、Xを見ていると、Lispとオブジェクトシステムという面白いドキュメントを見つけました。
どういう文章かというと、Lisp言語に対して、オブジェクト指向の機能を実装しようと、過去にはいろいろな方式が開発されていました。そのLispにおけるオブジェクト指向の方言を博物学的にまとめた記事となっています。今のオブジェクト指向プログラミング言語では、概ねクラスを定義し、それをnewしてインスタンスを生成する。というのがセオリーだと思いますが、黎明期のオブジェクト指向においては、用語の定義や、そもそも実装方式が洗練されておらず、少しずつ毛色の違う形の実装になっています。
例えば、CommonLispの場合を見てみると、
(defclass bank-account ()
((dollars :initform 0 :accessor dollars :initarg :dollars)))
(defgeneric deposit (a n))
(defmethod deposit ((a bank-account) (n number))
(incf (dollars a) n))
(defgeneric withdraw (a n))
(defmethod withdraw ((a bank-account) (n number))
(setf (dollars a)
(max 0 (- (dollars a) n))))
(defparameter *my-account* (make-instance 'bank-account :dollars 200))
(dollars *my-account*)
→ 200
(deposit *my-account* 50)
→ 250
(withdraw *my-account* 100)
→ 150
(withdraw *my-account* 200)
→ 0
となっています。読めなくはないし、理解もできると思いますが、現代のオブジェクト指向とは少し雰囲気の違うものである。という感触が分かりますでしょうか?そんなわけで、趣味的に読んでいたときに、ときめいた文章がありまして、
現代的な視点からすると、ハッシュテーブルとクロージャーがあれば、オブジェクト指向プログラミングシステム(OOPS)は作れそうだな、となりますが、実際Smalltalk登場後も登場前も色々な人々による様々な試行錯誤の結果としてOOPSやOOPSに類似したシステムは多数作られています。
「このハッシュとクロージャーがあれば、オブジェクト指向プログラミングシステム(OOPS)は作れそうだな」という言葉は、自分としては目から鱗が落ちました。例えばPythonだと、
def new_bank(dollars: int) -> dict[str, Any]:
attrs: dict[str, Any] = {"dollars": dollars}
def deposit(value: int) -> None:
attrs["dollars"] += value
def withdraw(value: int) -> None:
attrs["dollars"] = max(0, attrs["dollars"] - value)
attrs["deposit"] = deposit
attrs["withdraw"] = withdraw
return attrs
my_account = new_bank(200)
assert my_account["dollars"] == 200
my_account["deposit"](50)
assert my_account["dollars"] == 250
my_account["withdraw"](100)
assert my_account["dollars"] == 150
my_account["withdraw"](200)
assert my_account["dollars"] == 0
こんな感じで、それっぽいものは簡単に作れるんですね。でも、これはほぼPythonの言語機能に頼った構造で面白くない。
じゃあ、自分で考えた自分だけのオブジェクト指向システムを実装しよう!!
ということを行いました。
自作オブジェクト指向システムの概要
「オブジェクト指向とは何か」を書くと、いろいろ面倒なのですが、
- カプセル化
- 継承
- ポリモーフィズム
の3つの要素が組み込まれていると、最低限オブジェクト指向と言えそうです。(諸説あるんですが、ある一面的には、この3要素があればオブジェクト指向と認定する流派もいると思っています)
というわけで、作ってみたのが、こちらのリポジトリです。
どういうものが実装されているかというと、
- 属性に対するカプセル化
- public
- private
- readonly
- メソッドに対するカプセル化
- public
- private
- ポリモーフィズム
- 継承
- 多重継承
- 複数回の継承
あたりで、そこそこオブジェクト指向しているんじゃないかと思います。
実は、Lispのオブジェクトシステムを見たときに、これはオブジェクト指向ではないのでは?と感触がありました。それは、どうしてかというと、どのオブジェクトシステムの実装もカプセル化の概念はどれを見ても結構甘いな。という印象がありました。ネタ元の記事を読むと、これはLispプログラマの文化の面があるので、一概には否定はできないのですが、自分は、自作のオブジェクト指向システムにおいては、カプセル化もちゃんとしよう。と思って組み始めました。
自作オブジェクト指向システムの基本
作ってみたオブジェクトシステムのコードです。
def test_attr() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("yen")],
constructor=lambda sys: sys.send(
"this", "set-yen", value=sys.send("args", "get-yen")
),
)
system.send("env", "new", cls="bank", name="my-account", yen=100)
assert system.send(system.send("my-account", "get-yen"), "get-value") == 100
基本的に使う関数は、"send"のみです。このsend関数の引数によって全てを行います。sendの第1引数は、基本的にクラスのインスタンス(変数名or変数)で、第2引数がメソッド名です。それ以降のキーワード引数は、メソッドへの引数です。メソッドへは普通の引数は指定できません。
"env"というインスタンスは、すべての始まりのインスタンスです。初期化したときには、この変数しかなく、この変数へのメソッド呼び出し、メッセージ送信により、すべてを行います。
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("yen")],
constructor=lambda sys: sys.send(
"this", "set-yen", value=sys.send("args", "get-yen")
),
)
envに対し、defineというメッセージを送ると、クラスが定義できます。nameはクラス名、attrsは属性(クラス変数)、constructorはクラスを初期化するコンストラクタを表します。コンストラクタの第1引数はsystemが渡されます。先ほどのattrsで指定された属性は、自動でset-〇〇、get-〇〇といった関数が自動で実装され、それぞれsetter,getterになります。また、コンストラクタを呼ばれるときには、thisという変数が自動で生成されます。これは、いわゆるC++のthis,pythonのselfのような自分自身を表す変数として振舞います。また、argsと呼ばれる変数も設定されます。これは、引数を表し、get-〇〇というメソッドが作られ、引数として渡された値が取得できるようになっています。今回、クラスにはyenという属性を設定したため、get-yen,set-yenというメソッドが実装されました。そのため、自分自身を表すthisに対し、set-yenを呼び出すことができます。
system.send("env", "new", cls="bank", name="my-account", yen=100)
envに対し、newというメッセージを送ると、インスタンスが生成できます。clsはクラス名、nameはインスタンス変数名です。それ以降のキーワード引数は、コンストラクタに渡されます。今回は、my-accountという変数を作っています。
assert system.send(system.send("my-account", "get-yen"), "get-value") == 100
先ほど、my-accountという変数を生成できたので、sendにより直接メソッド呼び出しができるようになります。この変数に対し、自動で定義されたget-yenを呼び出すことができます。ここで、さらにget-valueを呼び出しているのは、整数型もオブジェクトであるため、そのオブジェクトが表す生の値を取り出すためにget-valueというメッセージを送っています。
このように、すべてをsendとメッセージの形で行う、割とSmalltalk風な構文を採用しています。
属性のアクセス制御に関する挙動
属性に関するアクセス修飾子は、public,private,readonlyの3種類あります。
アクセス修飾子 | 外部からの参照 | 外部からの変更 |
---|---|---|
public | 〇 | 〇 |
private | × | × |
readonly | 〇 | × |
割と素直な仕様にしました(たぶん)
挙動はこんなかたちで、アクセスに失敗すると例外が飛びます。
def test_public_attr() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
)
system.send("env", "new", cls="bank", name="my-account", dollars=100)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 100
system.send("my-account", "set-dollars", value=200)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 200
def test_private_attr() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PrivateAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
)
system.send("env", "new", cls="bank", name="my-account", dollars=100)
with pytest.raises(MethodAccessDenied):
system.send("my-account", "get-dollars")
with pytest.raises(MethodAccessDenied):
system.send("my-account", "set-dollars")
def test_readonly_attr() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[ReadonlyAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
)
system.send("env", "new", cls="bank", name="my-account", dollars=100)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 100
with pytest.raises(MethodAccessDenied):
system.send("my-account", "set-dollars", value=200)
あと、細かい仕様としてはアクセス修飾子である、PublicAttr,PrivateAttr,ReadonlyAttrの第2引数を渡すことで初期値を設定できます。
def test_default_attr_value() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("yen", 10)],
)
system.send("env", "new", cls="bank", name="my-account")
assert system.send(system.send("my-account", "get-yen"), "get-value") == 10
関数定義とアクセス制御に関する挙動
関数を定義する場合は、クラスの宣言時にmethod属性を指定します。宣言方法や、定義の方法、挙動もシンプルにしています。PublicMethodで囲まれた関数はインスタンス外部から呼び出し可能ですが、PrivateMethodで囲まれた関数はインスタンス外部から呼び出しできません。
def test_public_method() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
methods={
"deposit": PublicMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", "get-dollars"),
"add",
value=sys.send("args", "get-value"),
),
)
),
},
)
system.send("env", "new", cls="bank", name="my-account", dollars=100)
system.send("my-account", "deposit", value=50)
def test_private_method() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
methods={
"deposit": PrivateMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", "get-dollars"),
"add",
value=sys.send("args", "get-value"),
),
)
),
},
)
system.send("env", "new", cls="bank", name="my-account", dollars=100)
with pytest.raises(MethodAccessDenied):
system.send("my-account", "deposit", value=50)
ポリモーフィズムの挙動
サンプルのコードが長くなります。
def test_polymorphism() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
methods={
"deposit_by_dollar": PublicMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", f"get-dollars"),
"add",
value=sys.send("args", "get-value"),
),
)
),
"withdraw": PrivateMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", "get-dollars"),
"sub",
value=sys.send("args", "get-value"),
),
)
),
"send": PublicMethod(
lambda sys: (
sys.send(
sys.send("args", "get-to"),
"deposit_by_dollar",
value=sys.send("args", "get-amount"),
),
sys.send("this", "withdraw", value=sys.send("args", "get-amount")),
)
),
},
)
system.send(
"env",
"define",
name="japan_bank",
attrs=[PublicAttr("yen")],
constructor=lambda sys: sys.send(
"this", "set-yen", value=sys.send("args", "get-yen")
),
methods={
"deposit_by_dollar": PublicMethod(
lambda sys: sys.send(
"this",
"set-yen",
value=sys.send(
sys.send("this", "get-yen"),
"add",
value=sys.send(
sys.send("args", "get-value"), "multiply", value=150
),
),
)
),
},
)
system.send("env", "new", cls="bank", name="source_bank", dollars=100)
system.send("env", "new", cls="bank", name="target_dollar_bank", dollars=200)
system.send("env", "new", cls="japan_bank", name="target_yen_bank", yen=500)
system.send("source_bank", "send", to="target_dollar_bank", amount=50)
assert system.send(system.send("source_bank", "get-dollars"), "get-value") == 50
assert (
system.send(system.send("target_dollar_bank", "get-dollars"), "get-value")
== 250
)
system.send("source_bank", "send", to="target_yen_bank", amount=50)
assert system.send(system.send("source_bank", "get-dollars"), "get-value") == 0
assert (
system.send(system.send("target_yen_bank", "get-yen"), "get-value")
== 500 + 50 * 150
)
bankとjapan_bankという2つのクラスを定義します。これらには継承関係はありません。bankとjapan_bankの両方にdeposit_by_dollarという関数が定義されています。bankにはsendという関数が定義されており、toで指定されたインスタンスのdeposit_by_dollarを、amountで指定された量を引数に呼び出します。その後、自分自身のdollarをamountだけ下げます。どういうことかというと、A銀行からB銀行へ10(amount)ドル送金するとします。そうするときには、A銀行の残高を10ドル下げて(withdraw)、B銀行の残高を10ドル上げる(deposit_by_dollar)。というだけです。サンプルをよく見ると、先ほどの例のA銀行はbankとなっており、B銀行がbankであるかjapan_bankであるかに限らず送金(send)できている。ということが分かります。また、B銀行がjapan_bankであるときは、1ドル=150円として、残高が増えるようにふるまっています。
継承の挙動
これも割と素直な実装で、継承先のクラスでは、継承元のクラスの関数が呼び出せます。
以下の例では、japan_bankはbankを継承しています。
def test_inheritance_method() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
constructor=lambda sys: sys.send(
"this", "set-dollars", value=sys.send("args", "get-dollars")
),
methods={
"deposit_by_dollar": PublicMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", f"get-dollars"),
"add",
value=sys.send("args", "get-value"),
),
)
)
},
)
system.send(
"env",
"define",
name="japan_bank",
bases=["bank"],
attrs=[PublicAttr("yen")],
methods={
"deposit_by_yen": PublicMethod(
lambda sys: sys.send(
"this",
"set-yen",
value=sys.send(
sys.send("this", f"get-yen"),
"add",
value=sys.send("args", "get-value"),
),
)
),
},
)
system.send("env", "new", cls="japan_bank", name="my-account")
system.send("my-account", "set-dollars", value=100)
system.send("my-account", "set-yen", value=10)
system.send("my-account", "deposit_by_dollar", value=200)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 300
system.send("my-account", "deposit_by_yen", value=20)
assert system.send(system.send("my-account", "get-yen"), "get-value") == 30
また、多重継承にも対応しています。以下の例では、multi_bankはbankとjapan_bankを多重継承しています。
def test_multiple_inheritance() -> None:
system = ObjectOrientedSystem()
system.send(
"env",
"define",
name="bank",
attrs=[PublicAttr("dollars")],
methods={
"deposit_by_dollar": PublicMethod(
lambda sys: sys.send(
"this",
"set-dollars",
value=sys.send(
sys.send("this", "get-dollars"),
"add",
value=sys.send("args", "get-value"),
),
)
),
},
)
system.send(
"env",
"define",
name="japan_bank",
attrs=[PublicAttr("yen")],
methods={
"deposit_by_yen": PublicMethod(
lambda sys: sys.send(
"this",
"set-yen",
value=sys.send(
sys.send("this", "get-yen"),
"add",
value=sys.send("args", "get-value"),
),
)
),
},
)
system.send(
"env",
"define",
name="multi_bank",
bases=["bank", "japan_bank"],
methods={},
)
system.send("env", "new", cls="multi_bank", name="my-account")
system.send("my-account", "set-dollars", value=100)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 100
system.send("my-account", "deposit_by_dollar", value=200)
assert system.send(system.send("my-account", "get-dollars"), "get-value") == 300
system.send("my-account", "set-yen", value=10)
assert system.send(system.send("my-account", "get-yen"), "get-value") == 10
system.send("my-account", "deposit_by_yen", value=20)
assert system.send(system.send("my-account", "get-yen"), "get-value") == 30
継承にはいろいろな亜種があります。traitであったり、mixinであったり、C++だとpublic継承か、private継承か、protected継承か・・・他にも、多重継承に関しては菱形継承問題やC3線形化のアルゴリズムなど難しい問題もあります。私が今回作ったものは、基本的にはpublicな継承のみです。基底クラスと継承クラスで関数名がかぶることがありえますが、関数は概ね継承先のクラスから、深さ優先探索したとき最初に見つかるものが呼ばれる(はず)です。この辺は、割とToyな実装なので、まぁいっか。と思ってよくは考えていません。
プリミティブ型の挙動
ちょくちょく見え隠れしていますが、プリミティブ型も一部定義しています(int,floatのみ)。
このあたりのプリミティブ型もオブジェクト型をしており、"add"などのメッセージを送ることで演算が可能です。
def test_int() -> None:
system = ObjectOrientedSystem()
system.send("env", "new", cls="int", name="x", value=10)
assert system.send("x", "get-value") == 10
def test_int_add() -> None:
system = ObjectOrientedSystem()
system.send("env", "new", cls="int", name="x", value=10)
assert system.send(system.send("x", "add", value=5), "get-value") == 15
def test_int_add_vars() -> None:
system = ObjectOrientedSystem()
system.send("env", "new", cls="int", name="x", value=10)
system.send("env", "new", cls="int", name="y", value=5)
assert system.send(system.send("x", "add", value="y"), "get-value") == 15
感想
結構、ちゃんとしたものができたなぁ。という印象があります。特にsendですべてをまとめられたのは、個人的にはだいぶすっきりしました。ただ、かなりいろいろ紆余曲折があり、こだわりまくった結果、うーん。となってるのも事実です。実は、sendだけにこだわったために、envとthisの内部実装が綺麗ではなく、変数のスコープ管理に妙な分岐があったりします。また、envもインスタンスであるべき(オブジェクトであるべき)!と考えていたため、env内部で管理しているクラスとインスタンスの管理システムにenvも登録している。という自己言及的な構造のため、とてもデバッグしにくかったりします。最初期には、int型の足し算はそのままpythonの構文を利用していましたが、最終的にはオブジェクト指向側のシステムに型として導入し、純なオブジェクト指向を目指しました。結果、int型の演算が書きにくいな・・・とか、だんだん極限まで来ると、コマンドラインとその引数。とか、アセンブラ味を感じてきて、これは、本当にオブジェクト指向か・・・?と疑問になってきました。実装上、結構面倒だなぁ。と思ったのは、python自体の制限があって、lambdaには複数の文が書けないために、コンストラクタやメソッド定義に複雑なものが渡せないなぁ。と難儀していました。
なにをもってオブジェクト指向というのか?というのはよくわからない問いですが、自分自身もよくわからない気持ちになってきました。もともとは、いわゆる3要素、「カプセル化」「継承」「ポリモーフィズム」が導入された言語がオブジェクト指向な言語である。とあまり疑っていませんでした。しかし、先日、「オブジェクト指向のオブジェクトと型の関係が難しい」という記事を書いていて、疑問が出てきました。上記記事でも書きましたが、Pythonは全ての値がオブジェクトです。
Python における オブジェクト (object) とは、データを抽象的に表したものです。Python プログラムにおけるデータは全て、オブジェクトまたはオブジェクト間の関係として表されます。(ある意味では、プログラムコードもまたオブジェクトとして表されます。これはフォン・ノイマン: Von Neumann の "プログラム記憶方式コンピュータ: stored program computer" のモデルに適合します。)
上記の記事の内容に踏み込みますが、Pythonで生成される値の型は、すべからくobject型を継承しています。しかし、今回作ったオブジェクト指向で生まれるクラスは全てが共通となるクラス(object型)を継承しているわけではないです。そのため、今回作ったオブジェクト指向のシステムは全てがオブジェクトではないです。そうすると、これはオブジェクト指向なのか?という問いが生まれます。また、実はすべてがオブジェクトである。とは厳密にはどういうことなんだろう?ということは思いました。
また、それらとは別に、クラスやインスタンスがファーストクラスオブジェクトか?ということを考えていました。
第一級オブジェクト(ファーストクラスオブジェクト、first-class object)は、あるプログラミング言語において、たとえば生成、代入、演算、(引数・戻り値としての)受け渡しといったその言語における基本的な操作を制限なしに使用できる対象のことである。ここで「オブジェクト」とは広く対象物・客体を意味し、必ずしもオブジェクト指向プログラミングにおけるオブジェクトを意味しない。第一級オブジェクトは「第一級データ型に属す」という。
最初期に作っていたころは、インスタンスが返り値に指定できなかったり、引数に指定できなかったりしたのですが、「オブジェクト指向においてインスタンスはファーストクラスオブジェクトであるべきではないか?」と感じはじめ、できるだけファーストクラスオブジェクトになるように実装を近づけていました。私自身、あまり言語処理系のようなものを作った経験はありません。結果、クラスやインスタンスがint型やその他のプリミティブ型と同様にふるまうファーストクラスオブジェクトとして実装するのは、かなり難義しました。たぶん現状でも完ぺきではないでしょう。
最初は、LISP上にオブジェクト指向実装してる!キモイ!おもしろそう!ワイもやってみよ!ぐらいのノリで始めたのですが、よくよく考えると沼にはまっている気がします。オブジェクト指向を標榜する言語という意味では、
- すべてがオブジェクトである
- オブジェクトがファーストクラスオブジェクトである
も実は大切な要素なのではないかな。と思いました。「すべてがオブジェクトである」を実現しようと思うと何らかの「継承」の機能が必要だったりします。「継承」ができると「ポリモーフィズム」っぽいことも可能になってきます。逆に言えば、カプセル化はどっちでも・・・みたいな気持ちではあります。というより、自分のPython歴が長いため、まぁメンバ変数とか見えててもいいんじゃないかな・・・というスタンスではあります。もちろん個人で作る範囲であって、ビジネスで作る分には保守性の面から、カプセル化が欲しいのは事実です。でも、オブジェクト指向の一要素か?と言われると違うかな?という感触がありました。
そんなわけで趣味的にPythonの上にオブジェクト指向のシステムを作ってみました。やってみたらなんか学びというものあるもんですね。ちなみに会社のエンジニアの同僚にこの話をしたところ、1mmも分かってもらえない感じでした。そらそうよ。Pythonってそもそもオブジェクト指向の言語にオブジェクト指向を自作するって意味わからんでしょ。私も説明がめんどくさすぎて説明をあきらめました。