はじめに
多くのプログラマが最初に学ぶパラダイムの一つに「オブジェクト指向(OO)」があります。Python、Java、C++など、多くの言語で採用されており、データと振る舞いをクラスとして一つにまとめる(カプセル化)考え方は非常に強力です。
player.attack(enemy) のように、主体となるオブジェクトにメッセージを送る形で処理を記述すると、直感的で分かりやすくなります。
しかし、この「オブジェクトに振る舞いが属する」という考え方が、時としてコードの柔軟性を損なうことがあります。
-
外部ライブラリのクラスを拡張したい… どうしてますか?継承?それとも禁断のモンキーパッチ?
-
2つのオブジェクトが対等に関わる処理は、どちらのクラスに書くべき…?
player.collide_with(enemy)
? それともenemy.collide_with(player)
?
こうしたオブジェクト指向の「あるある」な悩みを、プログラミング言語Juliaの根幹をなす多重ディスパッチ (Multiple Dispatch) という仕組みが、驚くほどエレガントに解決してくれます。
この記事では、具体的なコード比較を通して、Juliaの多重ディスパッチがもたらす柔軟性と拡張性の高さを体感していただきたいと思います。
オブジェクト指向 vs 多重ディスパッチ
まず、言葉の整理です。
-
オブジェクト指向 (単一ディスパッチ)
-
object.method(arg1, arg2)
の形で呼び出す。 - どの
method
を実行するかは、object
の部分の型だけで決まる。これが単一ディスパッチ。
-
-
多重ディスパッチ
-
function(arg1, arg2, ...)
の形で呼び出す。 - どの実装を実行するかを、
arg1
,arg2
, ... すべての引数の型の組み合わせで決める。これが多重ディスパatches。
-
言葉だけだとピンとこないと思うので、さっそく「図形の交差判定」というお題でコードを書いて比較してみましょう。
お題:図形の交差判定 intersect
Circle
(円)と Rectangle
(長方形)という2種類の図形があり、これらが互いに交差しているか判定する intersect
という機能を実装します。
1. Python (オブジェクト指向) で書いてみる
まずはお馴染みのオブジェクト指向で書いてみます。Shape
という基底クラスを作り、Circle
とRectangle
がそれを継承する形にしましょう。
# --- python_shapes.py ---
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def intersect(self, other: 'Shape') -> bool:
pass
class Circle(Shape):
def __init__(self, name):
self.name = name
def intersect(self, other: Shape) -> bool:
# 相手の型によって処理を分岐させる必要がある
if isinstance(other, Circle):
print(f"Checking intersection: {self.name}(Circle) vs {other.name}(Circle)")
# ここに 円 vs 円 の判定ロジック
return True
elif isinstance(other, Rectangle):
print(f"Checking intersection: {self.name}(Circle) vs {other.name}(Rectangle)")
# ここに 円 vs 長方形 の判定ロジック
return True
else:
# 未知の型が来たら対応できない
return NotImplemented
class Rectangle(Shape):
def __init__(self, name):
self.name = name
def intersect(self, other: Shape) -> bool:
# Circle側と同様に、相手の型で分岐する必要がある
if isinstance(other, Circle):
print(f"Checking intersection: {self.name}(Rectangle) vs {other.name}(Circle)")
# ここに 長方形 vs 円 の判定ロジック
return True
elif isinstance(other, Rectangle):
print(f"Checking intersection: {self.name}(Rectangle) vs {other.name}(Rectangle)")
# ここに 長方形 vs 長方形 の判定ロジック
return True
else:
return NotImplemented
# --- 使ってみる ---
c1 = Circle("c1")
c2 = Circle("c2")
r1 = Rectangle("r1")
c1.intersect(c2)
# > Checking intersection: c1(Circle) vs c2(Circle)
c1.intersect(r1)
# > Checking intersection: c1(Circle) vs r1(Rectangle)
一見良さそうに見えます。しかし、ここに新しい図形 Triangle (三角形) を追加したくなったらどうでしょう?
class Triangle(Shape):
# ... intersectメソッドを実装 ...
pass
Triangle
クラスを追加するだけでは不十分です。既存のCircle
クラスとRectangle
クラスのintersect
メソッドに、elif isinstance(other, Triangle):
という分岐を追加して回らなければなりません。
つまり既存のコードを修正する必要があるのです。これはソフトウェア設計の「オープン・クローズドの原則(拡張に対して開いていて、修正に対して閉じているべき)」に反してしまいます。
2. Julia (多重ディスパッチ) で書いてみる
次に、同じことをJuliaで書いてみましょう。Juliaでは、まず「型」を定義し、その後に「関数(振る舞い)」を定義します。データと振る舞いが分離しているのがポイントです。
# --- julia_shapes.jl ---
# まずは「型」を定義する
# Shapeという抽象的な型を定義
abstract type Shape end
# Shapeを継承した具体的な型(構造体)を定義
struct Circle <: Shape
name::String
end
struct Rectangle <: Shape
name::String
end
# 次に「関数」を定義する
# 引数の型の組み合わせごとに、同じ名前の関数を複数定義できる
function intersect(c1::Circle, c2::Circle)
println("Checking intersection: $(c1.name)(Circle) vs $(c2.name)(Circle)")
# ここに 円 vs 円 の判定ロジック
return true
end
function intersect(c::Circle, r::Rectangle)
println("Checking intersection: $(c.name)(Circle) vs $(r.name)(Rectangle)")
# ここに 円 vs 長方形 の判定ロジック
return true
end
function intersect(r::Rectangle, c::Circle)
# 逆の組み合わせは、すでにある実装を呼び出すだけで良い!
return intersect(c, r)
end
function intersect(r1::Rectangle, r2::Rectangle)
println("Checking intersection: $(r1.name)(Rectangle) vs $(r2.name)(Rectangle)")
# ここに 長方形 vs 長方形 の判定ロジック
return true
end
# --- 使ってみる ---
c1 = Circle("c1")
c2 = Circle("c2")
r1 = Rectangle("r1")
intersect(c1, c2)
# > Checking intersection: c1(Circle) vs c2(Circle)
intersect(c1, r1)
# > Checking intersection: c1(Circle) vs r1(Rectangle)
intersect(c, r)
と intersect(r, c)
のように、引数の順番が違うだけで別の実装を定義できるのが面白い点です。これにより、処理を最適な場所に書くことができます。
さて、ここに新しい図形 Triangle
を追加してみましょう。
# 新しい型を追加
struct Triangle <: Shape
name::String
end
# 新しい型が関わる「関数」を「追加」するだけ
function intersect(t1::Triangle, t2::Triangle)
println("Checking intersection: $(t1.name)(Triangle) vs $(t2.name)(Triangle)")
return true
end
function intersect(c::Circle, t::Triangle)
println("Checking intersection: $(c.name)(Circle) vs $(t.name)(Triangle)")
return true
end
# ... 他の組み合わせも必要なら追加していく ...
# intersect(r::Rectangle, t::Triangle) = ...
# 既存のCircleやRectangleのコードには一切触る必要がない!
お分かりいただけたでしょうか?
Juliaでは、新しい型Triangle
を追加したとき、既存のCircle
やRectangle
のコードを一行も修正する必要がありませんでした。
ただ新しい型の定義と、それに関連するintersect
関数の実装を追加しただけです。
これこそ、多重ディスパッチがもたらす驚異的な拡張性です。
なぜ多重ディスパッチは嬉しいのか?
Juliaの多重ディスパッチは、オブジェクト指向が解決しようとした課題を、別のアプローチで、よりエレガントに解決します。
-
真の拡張性
既存のコードを汚すことなく、新しい型と振る舞いを自由に追加できます。ライブラリの作者が想定していなかった型に対しても、利用者が自由に関数を拡張できます。 -
自然な記述
a.collide(b)
のようにどちらかが主語になるのではなく、collide(a, b)
と対等に記述できます。これにより、ダブルディスパッチのような複雑なデザインパターンも不要になります。 -
発見可能性
intersect
という関数が、どのような型の組み合わせを処理できるのか知りたくなったら、methods
関数を呼ぶだけです。julia> methods(intersect) # 4 methods for generic function "intersect": [1] intersect(c1::Circle, c2::Circle) in Main at ... [2] intersect(c::Circle, r::Rectangle) in Main at ... [3] intersect(r::Rectangle, c::Circle) in Main at ... [4] intersect(r1::Rectangle, r2::Rectangle) in Main at ...
このように、関数が持つ能力の一覧を簡単に見ることができます。
まとめ
オブジェクト指向は、データと振る舞いをカプセル化することで、秩序あるプログラムの構築を可能にしました。一方で、その強い結びつきが、時には拡張性の足かせになることもあります。
Juliaの多重ディスパッチは、データ(型)と振る舞い(関数)をあえて分離し、それらを引数の型によって動的に結びつけます。
この「型と関数の分離」というパラダイムは、オブジェクト指向に慣れた頭には最初は奇妙に映るかもしれません。しかし、一度この世界の住人になると、その驚くべき柔軟性、拡張性、そして表現力の虜になるはずです。
もしあなたがPythonやJavaのコードの拡張性に悩んだことがあるなら、ぜひ一度、Juliaの多重ディスパッチがもたらす美しい世界を体験してみてください。