オーバーライドの仕組みをサンプルで

オーバーライドの仕組みを紹介するために、Javaでクラスベースのオブジェクト指向の仕組み(オーバーライドメソッドの動的束縛部分のみ)を簡単に作ってみる。

オブジェクトの構造

まず、このオブジェクト指向システムで利用するオブジェクトの構造を作ってみます。
10行くらいでできる。

public class MyObject {
    private MyClass thisClass; // このオブジェクトが属するクラス
    public MyObject(MyClass thisClass) {
        this.thisClass = thisClass;
    }
    // メソッドの起動
    public MyObject invoke(String name, MyObject...args) {
        // メソッドを自身のクラス情報から引き当てて実行
        MyMethod method = thisClass.resolveVirtual(name);
        return method.invoke(this, args);
    }
}

今回はかなり簡略化して、

  • オブジェクトが属するクラス

のみをそれぞれのオブジェクトが保持しています。
本来はインスタンスフィールドを用意しないとまったく実用的ではないのですが、Mapなフィールドでも定義すればいいやってことで割愛*1

オブジェクトの機能は

  • メソッド名と引数を指定してメソッドを起動

だけです。インスタンスフィールドを使いたければ、getField(String):MyObject, setField(String,MyObject)みたいなメソッドでも定義してやればいいと思います。

このプログラムでオブジェクトからメソッドを起動する際、実際に起動するメソッドを自身のクラスから探し出します。これがオブジェクト指向のポイントで、利用者から見れば全部ただの「オブジェクト」ですが、それぞれのオブジェクトは自身のクラスの情報を利用してメソッド呼び出しをすることができるので、同じメソッド呼び出しでもオブジェクトごとに異なる動作をさせることができます。

クラスの構造

つぎに、それぞれのオブジェクトが所属するクラスについて定義します。
少し大きくて30行くらい。

public final class MyClass {
    private Map<String, MyMethod> methodTable; // メソッドディスパッチテーブル
    // 新しいクラスを定義する
    public MyClass(MyClass base, MyMethod...methods) {
        methodTable = new HashMap<String, MyMethod>();
        if (base != null) { // 親クラスのメソッドディスパッチテーブルを継承
            methodTable.putAll(base.methodTable);
        }
        // メソッドディスパッチテーブルに、自身が定義するメソッドを追加
        for (MyMethod method : methods) {
            // 親クラスと名前が衝突したら上書き(=オーバーライド)
            methodTable.put(method.name, method);
        }
    }
    // インスタンスを生成する (コンストラクタ作るの面倒なのでスルー)
    public MyObject newInstance() {
        return new MyObject(this);
    }
    // メソッドを動的に束縛
    MyMethod resolveVirtual(String name) {
        // メソッドディスパッチテーブルから指定の名前を持つメソッドを探す
        MyMethod targetMethod = methodTable.get(name);
        if (targetMethod == null) { // なければエラー
            throw new NoSuchMethodError(name);
        }
        return targetMethod;
    }
}

まず、重要なのはコンストラクタの部分です。

if (base != null) { // 親クラスのメソッドディスパッチテーブルを継承
    methodTable.putAll(base.methodTable);
}
// メソッドディスパッチテーブルに、自身が定義するメソッドを追加
for (MyMethod method : methods) {
    // 親クラスと名前が衝突したら上書き(=オーバーライド)
    methodTable.put(method.name, method);
}

最初に親クラスのメソッドテーブルを子クラスのメソッドテーブルにコピーします。これで、親クラスで定義したメソッドを子クラスでも使えるようになります(メソッドの継承)。
そのあと、自身のクラスで定義されたメソッド一覧を先ほどのメソッドテーブル上にがりがりと追加してます。このとき、同じ名前のメソッドが親クラスで定義されていた場合、メソッドテーブル上では上書きします。
メソッドを探す部分は簡単で、HashMapの中の人にがんばってもらってます。

MyMethod resolveVirtual(String name) {
    // メソッドディスパッチテーブルから指定の名前を持つメソッドを探す
    MyMethod targetMethod = methodTable.get(name);
    ...
    return targetMethod;
}

オブジェクト指向言語コンパイラでは、メソッドの名前を数値化しておいて、メソッドテーブルを配列で表現するとかをよく見かけます。

ちなみに、MyMethodはこんな感じです。

public abstract class MyMethod {
    public final String name; // メソッド名
    public MyMethod(String name) {
        this.name = name;
    }
    // これを実装して処理を書く
    public abstract MyObject invoke(MyObject self, MyObject...args);
}

関数ポインタを作れないため、オーバーライドのサンプルを作るといいながらJavaのオーバーライドを使わないとメソッドが実装できません
ちなみに、それぞれのメソッドが自身の名前を知っているのは、こっちのほうが表記が楽だったからです。ふつうは知らなくても問題ありません。

ちなみに、クラスやメソッドの定義はこんな感じ。

MyClass Hoge = new MyClass(
    null, // 何も継承しない
    new MyMethod("hoge") { // hoge()
        public MyObject invoke(MyObject self, MyObject... args) {
            System.out.println("hoge");
            return null;
        }
    }
);
MyClass Foo = new MyClass(
    Hoge, // Hogeを継承
    new MyMethod("foo") { // foo()
        public MyObject invoke(MyObject self, MyObject... args) {
            System.out.println("foo");
            return null;
        }
    }
);

こんな感じのコードを書けます。

// var hoge = new Hoge();
MyObject hoge = Hoge.newInstance();
// var foo = new Foo();
MyObject foo = Foo.newInstance();
// hoge.hoge();
hoge.invoke("hoge");
// foo.foo();
foo.invoke("foo");
// foo.hoge() (継承したのも使える)
foo.invoke("hoge");

と、ここまで書いて気づいたのですが、組み込みデータとデータの加工に関する処理、あとI/Oくらいは入れないと何もできないですね。

組み込みデータ型を実装してみる

ここまでに作ったのがオブジェクトとクラスとメソッドで、

  • オブジェクトを作る
  • メソッドを呼び出す

以外の機能が何一つありませんでした。文字列を表現する組み込みデータ型でも作ってみます。

public final class MyString extends MyObject {
    // 文字列クラス
    private static final MyClass String = new MyClass(
        null, // extends null
        new MyMethod("print") { // print()
            public MyObject invoke(MyObject self, MyObject... args) {
                System.out.println(self.toString());
                return null;
            }
        },
        new MyMethod("concat") { // concat(String):String
            public MyObject invoke(MyObject self, MyObject... args) {
                String value = self.toString() + args[0].toString();
                MyString result = new MyString(value);
                return result;
            }
        });
    private final String value;
    // 引数にjava.lang.Strinigをとる
    public MyString(String value) {
        super(String); // Stringクラスのインスタンス
        this.value = value;
    }
    // toString で内容の文字列を返すことにする
    public String toString() {
        return value;
    }
}

こいつの機能はこんな感じです:

  • java.lang.Stringを利用して文字列を表現できる
  • print()メソッドで標準出力に自分自身を出力
  • concat(MyString)メソッドで自身と引数の文字列を連接した文字列を返す

いろいろとずるしてます。いわゆるプリイミティブとして扱っているので、システム自身で表現するのをあきらめました*2

こんな感じで使います。

// "Hello, world!".print();
new MyString("Hello, world!").invoke("print");
// "Hello".concat("World").print();
new MyString("Hello")
    .invoke("concat", new MyString("World"))
    .invoke("print");

普通に使ってみる

とりあえずはろわ。

// class Hoge {
//     function getValue() { return "Hello, Hoge!" }
// }
MyClass Hoge = new MyClass(
    null, // null
    new MyMethod("getValue") { // getValue():String
        public MyObject invoke(MyObject self, MyObject... args) {
            return new MyString("Hello, Hoge!");
        }
    });
// var hoge = new Hoge();
MyObject hoge = Hoge.newInstance();
// var value = hoge.getValue();
MyObject value = hoge.invoke("getValue");
// value.print();
value.invoke("print");

オーバーライドしてみる

まずは基底のクラスを作成。テンプレートメソッドパターンを使ってます。

// (abstract) class Base
MyClass Base = new MyClass(
    null, // extends null
    new MyMethod("start") { // start(String)
        public MyObject invoke(MyObject self, MyObject... args) {
            self.invoke("before"); // サブクラスで再定義しないとクラッシュ
            args[0].invoke("print");
            return null;
        }
    },
    new MyMethod("before") { // abstract before()
        public MyObject invoke(MyObject self, MyObject... args) {
            throw new AbstractMethodError(name);
        }
    });

beforeは抽象メソッドにしたかったのでAbstractMethodErrorをスローしてます。これでJavaと同じような動きをします。抽象メソッドは、「サブクラスで実装しないとコンパイラがエラーを出してくれる」というだけで、仕組みとしては具象メソッドと大差ありません。
で、このクラスを継承してbeforeをオーバーライドし、start()メソッドを呼び出すとそこからbefore()メソッドが呼び出されます。オーバーライドに成功していれば、エラーがスローされるのではなくオーバーライドメソッドが呼び出されるはず。

サブクラスはこんな感じで。それぞれbeforeを実装してます。

// class Hoge extends Base
MyClass Hoge = new MyClass(
    Base, // extends Base
    new MyMethod("before") { // @Override before()
        public MyObject invoke(MyObject self, MyObject... args) {
            new MyString("@Override by Hoge").invoke("print");
            return null;
        }
    });
// class Foo extends Base
MyClass Foo = new MyClass(
    Base, // extends Base
    new MyMethod("before") { // @Override before()
        public MyObject invoke(MyObject self, MyObject... args) {
            new MyString("@Override by Foo").invoke("print");
            return null;
        }
    });

テストコードはこんな感じで。

MyObject[] array = { Hoge.newInstance(), Foo.newInstance() };
for (MyObject each : array) {
    each.invoke("start", new MyString("Override Example"));
}

ちゃんと動いてる模様。

@Override by Hoge
Override Example
@Override by Foo
Override Example

例外がスローされることなくそれぞれのbeforeが実行されてます。

で、

クラスベースのオブジェクト指向は最適化コンパイラと相性がよくて、動的束縛されたメソッドをかなり高速に呼び出せます。今回の例でも、テーブルを1回参照するだけでメソッドが解決できました(さすがにHashMapでひくのは普通ないけど)。しかもコンパイル時にある程度推測できるケースもあるため、テーブルすら参照せずに決めうちでメソッドを実行してしまうという技法も見たことがあります。
ただ、クラスベースのオブジェクト指向+最適化コンパイラはプログラミングにいろいろな制約がついてしまうため、LLな人にはイマイチな感じです。トレードオフだとは思いますが。

次は静的に解決しているJavaオーバーロードでもやってみようかな。

*1:Javaではフィールドのオーバーライドとかはできません。子クラスで同じ名前のフィールドを定義すると、そのフィールドは親クラス上のフィールドを「隠す」といい、それぞれのオブジェクトがフィールドの領域を別々に持つことになります。

*2:一応がんばればできる