科学計算プログラミングにおけるオペレーターオーバーロードの重要性+juliaのメリット

301
NO IMAGE

前書き

PythonはAI、科学技術計算の世界ではデファクトスタンダートに近いですが、その理由は同じく科学技術計算で用いられるcやfortranと比べて書きやすく、そのためのライブラリも整っていることにあると思います。
しかし、パフォーマンスを出そうと思えばpython上でcやfortranのライブラリを使うこととなります。実際に科学計算でよく用いられるnumpyのライブラリの中身はcで書かれています。
対して、juliaはビルトインの機能自体が計算に向いており、ライブラリがなくともpythonと似たようなことができる利点があります。

本記事では、科学計算プログラミングを行う上で重要な機能となるオペレーターオーバーロードについて解説した上で、pythonと比べたjuliaのメリットについて解説しようと思います。

オーバーロードとは?

wikiの定義

多重定義 (たじゅうていぎ) あるいは オーバーロード (: overload) とは、プログラミング言語において同一の名前(シンボル)を持つ関数あるいはメソッドおよび同一の演算子記号について複数定義し、利用時にプログラムの文脈に応じて選択することで複数の動作を行わせる仕組みである。

例えば、どういうこと?

public class OverloadExample {

    // 整数同士の加算
    public static int addNumbers(int x, int y) {
        System.out.println("Adding two integers:");
        return x + y;
    }

    // 浮動小数点数同士の加算
    public static double addNumbers(double x, double y) {
        System.out.println("Adding two doubles:");
        return x + y;
    }

    // 文字列同士の連結
    public static String addNumbers(String x, String y) {
        System.out.println("Concatenating two strings:");
        return x + y;
    }

    // テスト
    public static void main(String[] args) {
        System.out.println(addNumbers(1, 2));
        System.out.println(addNumbers(1.0, 2.0));
        System.out.println(addNumbers("1", "2"));
    }
}

クラス内で3つのaddNumbersというメソッドが定義され、mainメソッドで呼び出されていますが、これはコンパイルエラーになりません。
どのメソッドが呼ばれるかは、メソッドに渡した引数の型(intだったり、doubleだったり)によって、コンパイル時にコンパイラ(上のコードではjavaのコンパイラ)が判断し、自動的に決定されます。

メソッドとそのメソッドに渡す型が密結合なのは当然ですが、それを人間が判断して選ぶ必要は必ずしもありません。
もしオーバーロードがなければ型の違いによってメソッドを選ばなければなりません。

オーバーロードを使用すると、同じ名前のメソッドや関数を複数定義できます。これにより、異なる引数の型や数に対応した処理をひとつのメソッド名で行うことができ、プログラミングの柔軟性と便利さが向上します。

if: オーバーロードがない世界線

public class NoOverloadExample {

    // 整数同士の加算
    public static int addIntegers(int x, int y) {
        System.out.println("Adding two integers:");
        return x + y;
    }

    // 浮動小数点数同士の加算
    public static double addDoubles(double x, double y) {
        System.out.println("Adding two doubles:");
        return x + y;
    }

    // 文字列同士の連結
    public static String concatenateStrings(String x, String y) {
        System.out.println("Concatenating two strings:");
        return x + y;
    }

    // テスト
    public static void main(String[] args) {
        System.out.println(addIntegers(1, 2));
        System.out.println(addDoubles(1.0, 2.0));
        System.out.println(concatenateStrings("1", "2"));
    }
}

※型によってメソッドを選ぶ図

オペレーターオーバーロードとは?

wikiの定義

利用者定義演算子 (りようしゃていぎえんざんし) とはプログラミング言語において、言語の利用者が演算子に対し組み込みの演算子とは異なる動作を定義できる機能である。

その名の通り、演算子(オペレーター)をオーバーロードして、ユーザー側で処理を書くことができます。

python: 2*2行列の計算

例として、オペレーターオーバーロードを使って、2*2行列の計算ができるようにしてみます。

class Matrix2D:
    def __init__(self, matrix):
        self.m1 = matrix[0][0]
        self.m2 = matrix[0][1]
        self.m3 = matrix[1][0]
        self.m4 = matrix[1][1]

    # 加算演算子のオーバーロード
    def __add__(self, other):
        return Matrix2D([[self.m1 + other.m1, self.m2 + other.m2],
                        [self.m3 + other.m3, self.m4 + other.m4]])

    # 減算演算子のオーバーロード
    def __sub__(self, other):
        return Matrix2D([[self.m1 - other.m1, self.m2 - other.m2],
                        [self.m3 - other.m3, self.m4 - other.m4]])

    # 乗算演算子のオーバーロード
    def __mul__(self, other):
        return Matrix2D([[self.m1 * other.m1 + self.m2 * other.m3, self.m1 * other.m2 + self.m2 * other.m4],
                        [self.m3 * other.m1 + self.m4 * other.m3, self.m3 * other.m2 + self.m4 * other.m4 ]])

    # 文字列表現のオーバーロード
    def __str__(self):
        return f"[{self.m1}, {self.m2}], [{self.m3}, {self.m4}]"

# テスト
matrix1 =[
        [1, 2],
        [3, 4]
    ]
matrix2 = [
        [5, 6],
        [7, 8]
    ]

m1 = Matrix2D(matrix1)
m2 = Matrix2D(matrix2)

# 加算のテスト
m3 = m1 + m2
print("m1 + m2:", m3)

# # 減算のテスト
m4 = m1 - m2
print("m1 - m2:", m4)

# # 乗算のテスト
m5 = m1 * m2
print("m1 * m2:", m5)

pythonの組み込み演算子では行列計算はサポートされていません。しかし、オペレーターオーバーロードを使えばただの足し算のように記述でき、より直感的に計算が行えます。
試しにオペレーターオーバーロードを用いず上記のプログラムを作成してみます。

オペレーターオーバーロードをしなかったら

class Matrix2D:
    def __init__(self, matrix):
        self.m1 = matrix[0][0]
        self.m2 = matrix[0][1]
        self.m3 = matrix[1][0]
        self.m4 = matrix[1][1]

    # 加算
    def add(self, other):
        return Matrix2D([[self.m1 + other.m1, self.m2 + other.m2],
                         [self.m3 + other.m3, self.m4 + other.m4]])

    # 減算
    def subtract(self, other):
        return Matrix2D([[self.m1 - other.m1, self.m2 - other.m2],
                         [self.m3 - other.m3, self.m4 - other.m4]])

    # 乗算
    def multiply(self, other):
        return Matrix2D([[self.m1 * other.m1 + self.m2 * other.m3, self.m1 * other.m2 + self.m2 * other.m4],
                         [self.m3 * other.m1 + self.m4 * other.m3, self.m3 * other.m2 + self.m4 * other.m4]])

    # 文字列表現のオーバーロード
    def __str__(self):
        return f"[{self.m1}, {self.m2}], [{self.m3}, {self.m4}]"

# テスト
matrix1 =[
    [1, 2],
    [3, 4]
]
matrix2 = [
    [5, 6],
    [7, 8]
]

m1 = Matrix2D(matrix1)
m2 = Matrix2D(matrix2)

# 加算のテスト
m3 = m1.add(m2)
print("m1 + m2:", m3)

# 減算のテスト
m4 = m1.subtract(m2)
print("m1 - m2:", m4)

# 乗算のテスト
m5 = m1.multiply(m2)
print("m1 * m2:", m5)

メソッドを使って計算する場合、m1.add(m2)のような計算式と異なる直感的でない表現になってしまいます。
また、この例では、1回演算しているだけですが、複数回行うとさらに読みにくくなっていきます。

m1.add(m2).subtract(m3).multiply(m4) // メソッド方式
( m1 + m2 - m3 ) * m4 // オペレーターオーバーロード方式

julia: 2*2行列の計算

最後に、juliaで同様の計算を行ってみます。

m1 = [
    1 2
    3 4
]

m2 = [
    5 6
    7 8
]

# 加算のテスト
m3 = m1 + m2
println("m1 + m2:", m3)

# # 減算のテスト
m4 = m1 - m2
println("m1 - m2:", m4)

# # 乗算のテスト
m5 = m1 * m2
println("m1 * m2:", m5)

以上です。

juliaでは言語レベルで行列計算がサポートされています。
これにより言語以外のライブラリを改めてインポートすることがなくとも計算を簡単に記述できます。
また、例に挙げたのはシンプルな計算のみでしたが、juliaに標準で組み込まれているLinear Algebraライブラリにより一通りの行列操作は行えます。

まとめ

今回はpythonで計算のためのクラスを作りましたが、実際にこんなことをする必要はなくnumpyのライブラリを使えばもちろん簡単に計算はできます。
しかし、juliaはライブラリのインポートも必要なく言語レベルで科学計算のための仕組みを組み込んでいる点がメリットになります。