🤹‍♂️

【Julia】オブジェクト指向と多重ディスパッチ

2024/12/23に公開

この記事では、Julia の大きな特徴である「多重ディスパッチ」を簡単で例をご紹介します。

この記事はプログラミング言語 Julia のアドベントカレンダー2024・シリーズ2の2日目の記事です。

https://qiita.com/advent-calendar/2024/julia

Juliaは2018年にバージョン1が公開されたオープンソースの科学技術計算言語で、 Fortranの様に高速でかつPythonの様に生産性の高い言語です。
Juliaは、オブジェクト指向ではなく、多重ディスパッチと型を用いたプログラミング様式を取ります。

多重ディスパッチとは、例えば同じ演算子 * を使っていても、引数の型が異なるときにはそれぞれ別々のメソッド(関数)が呼び出されるという仕組みです。
一般の関数で使うことが多いような気がしますが、今回は数学での親和性を考えて2項演算である「積」を例に見ていきます。

今回の例では、

  • MyReal:"自前"の実数の構造体(型)
  • MyMatrix:自前の2×2 の実数行列の構造体(型)

を定義し、多重ディスパッチのご利益(の一面)を見ます。
ここでは効果を強調して見るため、行列積はベタ書きしてみます。
(MyReal については、自前と書きましたがJulia の実装をそのまま使います)

また、C++ や Python などにおけるオブジェクト指向と比較することで、拡張のしやすさや、改造の容易さについても見ていきます。


0. 数学における「同じ記号の流用」

数学では、様々な掛け算が出てきます。
たとえば、abを実数とするとき、

a\times b

は実数の掛け算を意味します。一方で行列に対しても積が定義できます。
たとえば、AB2\times 2行列とするとき、i,j=1,2に対して、

[A\times B]_ {ij} = \sum_ {k=1}^2 A_ {ik} B_ {kj} = A_ {i1} B_ {1j} + A_ {i2} B_ {2j}

です。
a\times bA\times Bは中身・実体は異なりますが、
同じ「積」です。これを自然に実装できるのが多重ディスパッチという仕組みです[1]
数式をそのまま、プログラムに落とし込めるため、見た目も使いやすく、ユーザーフレンドリーになり、デバッグもやりやすくなります。


1. 型と多重ディスパッチ

ここでは、オブジェクト指向と多重ディスパッチを比較することで、多重ディスパッチとは何なんなのかを見ていきます。

C++ や Pythonなどでのオブジェクト指向

  • C++ や Python などでは、メソッドは クラス(オブジェクト)に属しています。
    • 例:Python で自前の型に対する掛け算 m1 * m2 の演算をしたいとき、m1 のクラスに __mul__ メソッドを実装する必要があります。
    • 「被演算子(オブジェクト)のメソッドを呼ぶ」という構造になるため、新たな型同士の組み合わせを扱いたい場合は クラス実装を複数箇所で変更・調整 しなければならないケースが増えてきます。

Julia の多重ディスパッチ

  • 一方、Julia では 「関数は型に属さず、関数(=演算子)と引数の型の組み合わせ」 で呼び出すメソッドが決まります。
  • 例えば * は Julia の標準ライブラリ(Base モジュール)の中にある「関数」です。そこへ (MyReal, MyReal) 型の組み合わせならこれ」「(MyMatrix, MyMatrix) 型の組み合わせならこれ」 と複数のメソッドを追加するだけで済みます。
  • 二項演算子に“持ち主”がいない ため、型の側のコードを直接触らなくても、あるいは別の型を変更することなく 新たなメソッドを追加 できます。

2. 既存コードの拡張性の容易性

コーディングにおいては、既存コードを改造、拡張することも多々あると思います。
ここではそれを見ています。

多重ディスパッチでの拡張性

  1. 型を後から追加しやすい

    • 「ある既存の型同士の演算」を別途拡張したい時、既存クラスの定義を直接書き換える必要がありません。
    • Base.:*(::新しい型, ::既存の型) のように、関数 (演算子) に対して「型の組み合わせ」を表すシグネチャを定義するだけで済みます。
  2. 演算子の振る舞いを柔軟に拡張

    • 例えば将来的に MyMatrix とまた別の型 MySparseMatrix(疎行列)との掛け算を追加したいとき、既存コードを大幅に改造することなく、Base.:*(::MyMatrix, ::MySparseMatrix) のメソッドを1つ書けば良いだけです。
    • 「演算子(関数)」と「型」が完全に分離しているため、オブジェクト指向における複雑な継承関係を意識する必要もありません。

応用・改造の容易さ

  • プラグイン的にコードを追加

    • 複数のモジュール(ファイル)に分割した大規模プログラムでも、各モジュールで「Base.:*(::TypeA, ::TypeB) の実装」をポンと書けば、既存のモジュールのコードを改変する必要なしに新機能を追加できます。
    • これは、オブジェクト指向言語のように既存クラスにメソッドを追加するために既存クラスを修正するのとは対照的です。
  • 型の増加に強い

    • 大規模プログラムでは多様なデータ型が増えるほど「この型とこの型を掛け算したらどうなるのか?」といったケースも増えてきます。Julia の多重ディスパッチを使えば、その都度新たな組み合わせに応じたメソッドを追加すれば良いだけです。
    • コードの変更範囲が「まさにその組み合わせに関わるメソッド」だけに限定されるため、安心して拡張できます。

3. コード例

以下では、実演として 2 つの構造体を定義し、同じ演算子 * にそれぞれ別のメソッドを割り当てます。

  1. MyReal

    • 実数を包む構造体。x::Real のフィールドを持ちます。
  2. MyMatrix

    • 2×2 の実数行列を保持する構造体。A::Matrix{Float64} で行列を保持。
    • 行列積はベタ書き(要素をひとつひとつ計算)してみます。
# ================
# 1. 新しい型の定義
# ================

# 1.1 MyReal: "自前"の実数の型
struct MyReal
    x::Real # 中身はJulia の実数を入れておく
end

# 1.2 MyMatrix: "自前"の2×2 の実数行列の型
#     ここでは浮動小数点行列を想定しているので Matrix{Float64} を使います。
struct MyMatrix
    A::Matrix{Float64}  # 2×2 の行列を想定
end

# =====================
# 2. 掛け算のメソッド定義
# =====================

# 2.1 MyReal 同士の掛け算
#     a::MyReal, b::MyReal → 実数の掛け算をして MyReal で包んで返す
Base.:*(a::MyReal, b::MyReal) = MyReal(a.x * b.x)

# 2.2 MyMatrix 同士の掛け算
#     M1::MyMatrix, M2::MyMatrix → 2×2 行列積を要素ごとに計算
Base.:*(M1::MyMatrix, M2::MyMatrix) = begin
    A = M1.A  # M1 の 2×2 行列
    B = M2.A  # M2 の 2×2 行列
    
    # 2×2 行列積 C = A * B をベタ書きで計算
    C11 = A[1,1]*B[1,1] + A[1,2]*B[2,1]
    C12 = A[1,1]*B[1,2] + A[1,2]*B[2,2]
    C21 = A[2,1]*B[1,1] + A[2,2]*B[2,1]
    C22 = A[2,1]*B[1,2] + A[2,2]*B[2,2]
    
    # 結果の行列を MyMatrix で包んで返す
    MyMatrix([C11 C12; C21 C22])
end

# ==========================
# 3. 実際に掛け算を試してみる
# ==========================

# 3.1 MyReal 同士の掛け算
r1 = MyReal(2.0)
r2 = MyReal(3.0)
result_real = r1 * r2 # 自前の実数型同士の掛け算。自然に書ける。
println("r1 * r2 = ", result_real)  

# 3.2 MyMatrix 同士の掛け算
mat1 = MyMatrix([1.0 2.0; 
                 3.0 4.0])
mat2 = MyMatrix([2.0 0.0; 
                 1.0 2.0])

result_mat = mat1 * mat2 # 行列積だが、同じように書ける!

println("mat1 * mat2 = ", result_mat)

4. 実行結果

上記コードを実行すると、以下のように表示されます。

r1 * r2 = MyReal(6.0)
mat1 * mat2 = MyMatrix([4.0 4.0; 10.0 8.0])

ここで注目してほしいのは、同じ * 演算子でも、引数が MyReal の場合と MyMatrix の場合で 自動的に異なるメソッド が選ばれている点です。これは Julia の多重ディスパッチの恩恵です。


5. まとめ

  1. Julia の多重ディスパッチ

    • 関数(演算子)はあくまで独立して存在し、引数の型に応じて呼び出されるメソッドが動的に決まる。
    • 二項演算子に「持ち主」がいないため、既存の型を変更せずに新たな組み合わせのメソッドを追加 できる。
  2. 拡張性の高さ・大規模プログラムへの適用

    • 既存クラスを編集することなく、新しい型や新しい演算の組み合わせを外部からプラグイン的に追加可能。
    • 大規模プログラムにおいて型が増えても、その組み合わせの分だけメソッドを追加すれば良い。コードの分散管理にも適している。
  3. オブジェクト指向言語との違い

    • オブジェクト指向言語では演算処理はクラス内部に記述しがちで、複数型間の演算を追加する際は複雑になりやすい。
    • Julia では「関数+型の組み合わせ」の形で定義するだけなので、後から容易に拡張 できる。

このように、Julia の多重ディスパッチは数式をプログラムに落とし込むのに役立ち、大規模プログラムにおいて 保守性や拡張性を高める のにも大きな利点があります。
また、シングル実装やパラレル実装を見た目で同じように書けます。

良いJulia life を。

関連記事

Julia言語における中置演算子の扱い

脚注
  1. もちろん多重ディスパッチでなくても実装はできますがかなり自然な仕組みだと思います。 ↩︎

Discussion