M.Hiroi's Home Page

Functional Programming

お気楽 Scheme プログラミング入門

オブジェクト指向編

[ PrevPage | Scheme | NextPage ]

オブジェクト指向の基礎知識

プログラミングに興味のある方ならば、「オブジェクト指向」という言葉は聞いたことがあると思います。よく使われているオブジェクト指向言語に C++ や Java があります。また、Lightweight Language と呼ばれているプログラミング言語、たとえば Perl, Python, Ruby, JavaScript などはオブジェクト指向をサポートしています。

多くの言語でサポートされている「オブジェクト指向」ですが、関数型言語では Common Lisp の CLOS (Common Lisp Object System) が有名でしょう。CLOS は Smalltalk, C++, Java などのポピュラーなオブジェクト指向とはちょっと違っていて、興味深い機能がたくさんあります。クラス、インスタンス、メソッド、継承などの一般的なオブジェクト指向機能のほかに、総称関数 (generic function) やメソッド結合 (method combination) などのユニークな機能があります。

Scheme にはオブジェクト指向機能に関する規定がないので、そのサポートは処理系に依存します。Scheme で記述されたポータブルなオブジェクト指向システムもあり、その中では CLOS の流れを汲む Tiny CLOS が有名です。Gauche のオブジェクト指向システムも CLOS の影響を強く受けています。

CLOS は巨大なオブジェクト指向システムなので、その仕様を理解するのはとても大変ですが、CLOS は自然な形で Common Lisp を拡張しているので、基本的な機能はとても簡単に使うことができます。これは Gauche のオブジェクト指向システムも同じなので、基本的なところから少しずつ勉強していくことにしましょう。まず最初に、一般的なオブジェクト指向について簡単に説明します。

なお、この説明は拙作のページ Lightweight Language お気楽 Python プログラミング入門第 5 回 と同じです。既に読んだことがある方や、一般的なオブジェクト指向について理解されている方は、読み飛ばしてもらってもかまいません。

次へ

●オブジェクトとは?

プログラムを作る場合、全体を小さな処理に分割して、ひとつひとつの処理を作成し、それらを組み合わせて全体のプログラムを完成させます。このとき、基本的な部品となるのが関数です。つまり、処理を関数単位で分割して、それらを組み合わせてプログラムを作るわけです。もともと関数の役割は、入力されたデータを処理してその結果を返すことです。つまり、関数は機能を表しているのです。このため、全体を小さな処理に分割するにしても、機能単位で行われることが普通です。

オブジェクト指向プログラミングでは、関数ではなく「オブジェクト (object) 」を部品として扱います。たとえば、えんぴつを考えてみましょう。えんぴつには、色、長さ、固さ、などいろいろな性質があります。そして、えんぴつを使って文字を書いたり、絵を描いたりすることができます。プログラムでは、このような性質をデータで表し、機能を関数で表すことになります。そしてオブジェクトとは、このデータと関数を結び付けたものなのです。

いままでのプログラミング言語では、データと関数を別々に定義するため、それをひとつのオブジェクトとして表すことができません。えんぴつで文字を書くにも、えんぴつの種類をチェックして文字を書くようにプログラムしなければいけません。ところが、オブジェクトはデータと関数を結び付けたものなので、自分がなにをしたらよいかわかっています。えんぴつオブジェクトに文字を書けと命じれば、それが赤えんぴつのオブジェクトであれば文字は赤に、黒えんぴつのオブジェクトであれば黒い文字になるのです。

このように、オブジェクトはデータと関数をひとつにまとめたものです。従来のプログラミングが全体を機能単位で分割するのに対し、オブジェクト指向プログラミングでは全体をオブジェクト単位に分割して、それを組み合わせることでプログラムを作成します。

ところで、データと関数を結び付けることは、従来のプログラミング言語でも可能です。オブジェクト指向はプログラミングの考え方のひとつであり、C++ のようなオブジェクト指向言語を使わなくても、たとえばC言語でもその考え方にしたがってプログラムを作れば、オブジェクト指向プログラミングになります。

実際、オブジェクト指向には様々な考え方があり、いろいろなオブジェクト指向言語が存在します。ですが、データと関数をひとつにまとめたものをオブジェクトとして扱うという基本的な考え方は、オブジェクト指向言語の元祖と言われる Smalltalk でも、C++, Java, Gauche (CLOS) でも同じです。

●クラスとインスタンス

次は、一般的なオブジェクト指向機能について簡単に説明します。

「クラス (class) 」はオブジェクトの振る舞いを定義したものです。ここでデータを格納するための変数や、それを操作する関数が定義されます。この変数をメンバ変数とかインスタンス変数といい、関数を「メソッド (method) 」といいます。メソッドはあとで説明します。

クラスはオブジェクトの設計図にあたるもので、オブジェクトの「雛形」と呼ぶこともあります。クラスはオブジェクトの振る舞いを定義するだけで、アクセスできる実体はなにも生み出していない、ということに注意してください。

このクラスから実体として作り出されるのが「インスタンス (instance) 」です。このインスタンスを「オブジェクト」と考えてください。インスタンスを生成する方法は、当然ですがプログラミング言語によって違います。たとえば C++ や Java は new を使います。図 1 を見てください。


               図 1 : クラスとインスタンスの関係

クラスはオブジェクトの定義を表すものですから、Foo というクラスはひとつしかありません。これに対し、インスタンスはクラスから生み出されるオブジェクトです。たとえば、クラス Foo に new を適用することで、いくつでもインスタンスを生み出すことができるのです。クラスは設計図であり、それに従って作られるオブジェクトがインスタンスと考えるとわかりやすいでしょう。

●メソッド

メソッドはオブジェクトと結びついた関数です。オブジェクト指向プログラミングでは、ほかの関数から直接オブジェクトを操作することはせず、メソッドを呼び出すことで行います。メソッドは、クラスが異なっていれば同じ名前のメソッドを定義することができます。たとえば、クラス Foo1 にメソッド bar() が定義されていても、クラス Foo2 に同名のメソッド bar() を定義することができます。

そして、ここからが重要なのですが、あるオブジェクトに対してメソッド bar() を呼び出した場合、それが Foo1 から作られたオブジェクトであれば、Foo1 で定義された bar() が実行され、Foo2 から作られたオブジェクトであれば、Foo2 で定義された bar() が実行されるのです。

このように、オブジェクトが属するクラスによって、実行されるメソッドが異なるのです。この機能を「ポリモーフィズム(polymorphism) 」と呼びます。これにより、オブジェクトは自分が行うべき適切な処理を実行できるわけです。

クラス、インスタンス、メソッドの関係は図 2 のようになります。


         図 2 : クラス、インスタンス、メソッドの関係

クラスという設計図が中心にあり、そこからインスタンスが生み出され、メソッドを使ってインスタンスを操作する、という関係になります。

●クラスの定義

さて、一般的な話はここまでにして、Gauche のオブジェクト指向機能に目を向けてみましょう。Gauche の場合、クラスはマクロ define-class を使って定義します。define-class の構文を次に示します。

(define-class クラス名 (スーパークラス ...)
  ((スロット名 :option-11 value-11 ... :option-1n value-1n)
    ・・・・
   (スロット名 :option-n1 value-1n ... :option-nn value-nn)))

define-class の次にクラス名をシンボルで指定し、その次にスーパークラスをリストで指定します。Gauche は「多重継承」をサポートしているので、リスト内に複数のスーパークラスを指定することができます。継承はあとで詳しく説明します。

「スロット (slot) 」とは、クラスで定義した変数のことです。スロット名の指定にはシンボルを使います。スロットは変数と同様に S 式であれば何でも格納することができます。スロットのあとにスロットオプションを指定することができます。オプションの指定にはキーワードを使います。キーワードは "コロン ( : ) + 名前" で表される特別なシンボルです。キーワードについては後で詳しく説明します。

define-class でよく使われるスロットオプションに :accessor, :init-value, :init-form, :init-keyword などがあります。

ここで、注意してもらいたいことがあります。クラスはオブジェクトの雛形であり、アクセスできる実体は何も生み出していないのです。アクセスできる実体としてクラスから生み出されるものが「インスタンス (instance) 」です。Gauche の場合、このインスタンスを「オブジェクト」と考えてください。

●キーワード

キーワードは特別なシンボルで、その値は自分自身に初期化され、値を書き換えることはできません。Common Lisp のキーワードはシンボルですが、Gauche の場合はキーワードというデータ型として定義されていて、シンボルと区別されています。

キーワードは名前の先頭にセミコロン ( : ) を付けて表します。または、関数 make-keyword で生成することもできます。簡単な例を示しましょう。

gosh> :a
:a
gosh> (make-keyword "foo")
:foo

キーワードで引数の値を指定することを「キーワード引数」といいます。キーワード引数は可変個引数で受け取ります。このとき、リストの要素はキーと値が交互に並んだ状態になります。Gauche の場合、関数 get-keyword でキーワードの値を取得することができます。

get-keyword key key-value-list [fallback]

get-keyword は key が見つからない場合はエラーを送出しますが、fallback が指定されている場合はその値を返します。簡単な例を示しましょう。

gosh> (get-keyword :a '(:a 1 :b 2 :c 3))
1
gosh> (get-keyword :c '(:a 1 :b 2 :c 3))
3
gosh> (get-keyword :d '(:a 1 :b 2 :c 3) #f)
#f

最後の例では、キーワード :d が見つからないので fallback の値 #f を返します。

●汎変数

Gauche の場合、データの代入は set! で行えるように一般化されています。Common Lisp のマクロ setf と同じ使い方で、これを「汎変数」といいます。set! の構文を示します。

set! (アクセス関数 引数 ...) 値

アクセス関数は評価したときに値を取り出す関数のことです。set! はアクセス関数が示す位置へデータを代入します。たとえば、変数は評価するとその値を返すので、アクセス関数とみなすことができます。また、car や cdr はリストのアクセス関数なので、set! と組み合わせて使うことができます。

簡単な例を示しましょう。

gosh> (define a '(1 . 2))
a
gosh> (set! (car a) 10)
#<undef>
gosh> a
(10 . 2)
gosh> (set! (cdr a) 20)
#<undef>
gosh> a
(10 . 20)
gosh> (define b '((1 . 2) (3 . 4) (5 . 6)))
b
gosh> (set! (caar b)) 10)
#<undef>
gosh> b
((10 . 2) (3 . 4) (5 . 6))
gosh> (set! (caadr b) 100)
#<undef>
gosh> b
((10 . 2) (100 . 4) (5 . 6))
gosh> (define c (make-vector 10 0))
c
gosh> c
#(0 0 0 0 0 0 0 0 0 0)
gosh> (set! (vector-ref c 5) 10)
#<undef>
gosh> c
#(0 0 0 0 0 10 0 0 0 0)

ところで、すべてのアクセス関数で set! が使えるわけではありません。たとえば、list-ref は set! と組み合わせて使うことはできません。アクセス関数を汎変数で使うには、アクセス関数に対応する書き込み用の関数 (セッター : setter) を設定する必要があります。セッターなどの詳しい説明は Gauche のユーザリファレンス 4.4 代入 をお読みください。

●インスタンスの生成

インスタンスは総称関数 make を使って生成します。総称関数はメソッドの定義で簡単に説明しますが、ここでは make を関数と考えてもらってかまいません。

make クラス [キーワード S式 ...]

スロットは make でインスタンスを生成するときに初期化されます。初期値はキーワードの後ろの S 式になります。make は関数なので、S 式は評価されることに注意してください。この初期値は :init-keyword で指定したキーワードに対応するスロットにセットされます。make でスロットの初期値が省略された場合は、define-class の :init-value や :init-form で設定した値をスロットにセットします。

簡単な使用例を示しましょう。

gosh> (define-class <foo> () ((a :accessor foo-a :init-value 0 :init-keyword :a)))
<foo>
gosh> <foo>
#<class <foo>>
gosh> (define x (make <foo>))
x
gosh> (foo-a x)
0
gosh> (set! (foo-a x) 10)
#<undef>
gosh> (foo-a x)
10
gosh> (define y (make <foo> :a (+ 10 20)))
y
gosh> (foo-a y)
30

define-class でクラス <foo> を定義します。Tiny CLOS や Gauche の場合、クラス名は < > で囲む習慣があります。<foo> にはスロット a が定義されています。:accessor に foo-a を指定しているので、スロット a にアクセスするメソッド foo-a が生成されます。それから、:init-value に初期値 0 を、:init-keyword にキーワード :a を指定します。

次に、make でクラス <foo> のインスタンスを生成し、変数 x にセットします。スロット a は :accessor で指定した foo-a でアクセスすることができます。実際に (foo-a x) を評価すると値は 0 になります。スロット a は :init-value の値 0 で初期化されていることがわかります。また、(set! (foo-a x) 10) とすれば、スロット a の値を 10 に書き換えることができます。

make でスロットの初期値を指定する場合、:init-keyword で指定したキーワード :a を使います。このとき、:init-value で指定された初期値は無効になります。実際に make で :a に (+ 10 20) を指定してインスタンスを生成すると、スロット a の値は 30 に初期化されます。

●メソッドの定義

メソッドは特定のクラスと結びついた関数です。メソッドの特徴は、同じ名前のメソッドをいくつも定義することができ、引数のクラスによって、その中から実際に呼び出すメソッドを自動的に選択することです。該当するメソッドが見つからない場合はエラーとなります。

メソッドの定義にはマクロ define-method を使います。

define-method メソッド名 ((仮引数名 クラス名) or 引数 ... ) S式 ...

define-method は define と構造がよく似ていますが、仮引数リストが異なっています。仮引数名を表すシンボルとクラスをリストに格納して表します。クラス名を省略した場合は、すべてのデータとマッチングすることになります。クラス名は引数のデータ型を特定することになるので「引数特定子」と呼ばれます。

CLOS や Gauche では同一名のメソッドの集まりを「総称関数 (generic function) 」と呼びます。この総称関数がC++や Java などのオブジェクト指向とはちょっと違う Gauche (CLOS) の特徴です。Gauche の場合、クラス、インスタンス、メソッドの関係は次のようになります。

Gauche や CLOS の場合、メソッドはクラスの中に定義されているのではありません。個々のメソッドは総称関数に登録され、呼び出されるときに引数のクラスによって適切なメソッドが選択されるのです。クラスとメソッドの結び付きはC++や Java よりも弱いですが、そのかわり柔軟なプログラミングが可能になります。

それでは簡単な使用例を示します。

gosh> (define-class <foo> () ())
<foo>
gosh> (define-class <bar> () ())
<bar>
gosh> (define-method baz ((x <foo>)) (print "foo!"))
#<generic baz (1)>
gosh> (define-method baz ((x <bar>)) (print "bar!"))
#<generic baz (2)>
gosh> (define a (make <foo>))
a
gosh> (define b (make <bar>))
b
gosh> (baz a)
foo!
#<undef>
gosh> (baz b)
bar!
#<undef>

メソッド baz が 2 つ定義されています。どちらのメソッドも総称関数 bar に登録されます。そして、総称関数 bar を呼び出すとき、引数 x がクラス <foo> のインスタンスであれば、最初に定義したメソッドが選択され foo! が表示されます。引数 x がクラス bar のインスタンスであれば、次に定義したメソッドが選択されるので bar! が表示されます。このように、Gauche は総称関数 bar を呼び出すとき、引数のクラスによってその中から適切なメソッドを選択して実行するのです。

ほかのオブジェクト指向、たとえばC++や Java では、メソッドはクラスの中で定義されます。メソッドを呼び出す場合、C++であれば a.bar() のように、オブジェクト a が属するクラス foo1 で定義されているメソッド bar を呼び出す、という形式になります。ところが Gauche や CLOS の場合、複数の引数にクラスを指定して、適切なメソッドを呼び出すことができます。次の例を見てください。

gosh> (define-method baz2 ((x <foo>) (y <foo>)) (print "foo foo!"))
#<generic baz2 (1)>
gosh> (define-method baz2 ((x <bar>) (y <bar>)) (print "bar bar!"))
#<generic baz2 (2)>
gosh> (define-method baz2 ((x <foo>) (y <bar>)) (print "foo bar!"))
#<generic baz2 (3)>
gosh> (define-method baz2 ((x <bar>) (y <foo>)) (print "bar foo!"))
#<generic baz2 (4)>
gosh> (baz2 a a)
foo foo!
#<undef>
gosh> (baz2 a b)
foo bar!
#<undef>
gosh> (baz2 b b)
bar bar!
#<undef>
gosh> (baz2 b a)
bar foo!
#<undef>

メソッドを定義する場合、引数の個数は同じでなければいけません。たとえば、最初に引数 2 個のメソッド baz2 を定義したら、引数 1 個の baz2 は定義できないのです。ご注意ください。

ここではメソッド baz2 を 4 つ定義していますが、引数 x と y のクラスにより適切なメソッドが選択されます。もしも、引数 y はどんなデータでもよければ、次のようにクラスの指定を省略することで実現できます。

gosh> (define-method baz2 ((x <foo>) y) (print "foo ***"))
#<generic baz2 (5)>
gosh> (baz2 a 10)
foo ***
#<undef>

引数 y にはクラスの指定がないことに注意してください。このように引数 x が <foo> で引数 y が整数値の場合は、ここで定義したメソッドが選択されます。ただし、引数 y が <foo> や <bar> の場合は、前に定義したメソッドが選択されます。

●スロットのアクセス

ここで、スロットのアクセスで役に立つ関数を紹介しましょう。今までは define-class の :accessor で指定したメソッドを使ってスロットにアクセスしました。このほかに、スロットは関数 slot-ref でアクセスすることができます。

slot-ref インスタンス スロット名

slot-ref はインスタンスのスロットに格納されている値を返します。また、set! と slot-ref を使ってスロットの値を更新することができます。簡単な使用例を示しましょう。

gosh> (define-class <foo> () ((a :init-value 10) (b :init-value 20)))
<foo>
gosh> (define x (make <foo>))
x
gosh> (slot-ref x 'a)
10
gosh> (slot-ref x 'b)
20
gosh> (slot-ref x 'c)
*** ERROR: object of class #<class <foo>> doesn't have such slot: c

gosh> (set! (slot-ref x 'a) 100)
#<undef>
gosh> (slot-ref x 'a)
100

スロット a, b を持つクラス <foo> を定義します。define-class の :assessor にはメソッドの指定がないことに注意してください。次に、make でクラス <foo> のインスタンスを生成して変数 x にセットします。

slot-ref は :accessor にメソッドの指定がなくても、スロットにアクセスすることができます。slot-ref でスロット a, b の値を求めると 10 と 20 になります。slot-ref は関数なので引数は評価されます。スロット名はクオートすることをお忘れなく。また、(slot-ref x 'c) はクラス foo にスロット c が存在しないのでエラーになります。

次に、(set! (slot-ref x 'a) 100) でスロット a に 100 をセットします。そのあと slot-ref でスロット a の値を求めると 100 に書き換えられていることがわかります。なお、Gauche にはスロットの値を書き換える関数 slot-set! も用意されています。

slot-set! インスタンス スロット名 値

簡単な例を示します。

gosh> (slot-set! x 'b 200)
#<undef>
gosh> (slot-ref x 'b)
200

また、slot-ref のメソッドバージョン ref もあります。

gosh> (ref x 'a)
100
gosh> (ref x 'b)
200

ref も set! と組み合わせてスロットの値を書き換えることができます。

●<point> クラス

最後に簡単な例として、点を表すクラスを作ってみましょう。名前は <point> にしました。x 座標をスロット x に、y 座標をスロット y に格納します。リスト 1 を見てください。

リスト 1 : <point> クラス

(define-class <point> ()
  ((x :accessor get-x :init-value 0 :init-keyword :x)
   (y :accessor get-y :init-value 0 :init-keyword :y)))

; 2 点間の距離を求める
(define-method distance ((p1 <point>) (p2 <point>))
  (let ((dx (- (get-x p1) (get-x p2)))
        (dy (- (get-y p1) (get-y p2))))
    (sqrt (+ (* dx dx) (* dy dy)))))

define-class の :accessor でスロット x, y のアクセス関数 get-x, get-y を定義します。メソッド distance は <point> クラスのインスタンスを 2 つ受け取り、その距離を計算します。その中でアクセスメソッド get-x, get-y を呼び出して、スロット x, y の値を取得します。

それでは実際に試してみましょう。

gosh> (define a (make <point>))
a
gosh> (define b (make <point> :x 10 :y 10))
b
gosh> (distance a b)
14.142135623730951

次は、3 次元の座標を表す <point3d> クラスを作ります。リスト 2 を見てください。

リスト 2 : <point3d> クラス

(define-class <point3d> ()
  ((x :accessor get-x :init-value 0 :init-keyword :x)
   (y :accessor get-y :init-value 0 :init-keyword :y)
   (z :accessor get-z :init-value 0 :init-keyword :z)))

(define-method distance ((p1 <point3d>) (p2 <point3d>))
  (let ((dx (- (get-x p1) (get-x p2)))
        (dy (- (get-y p1) (get-y p2)))
	(dz (- (get-z p1) (get-z p2))))
    (sqrt (+ (* dx dx) (* dy dy) (* dz dz)))))

クラス <point3d> は <point> を 3 次元に拡張しただけです。このように、<point> でも <point3d> でも距離を計算するメソッド distance を定義することができます。

それでは実行してみましょう。

gosh> (define c (make <point3d>))
c
gosh> (define d (make <point3d> :x 10 :y 10 :z 10))
d
gosh> (distance c d)
17.320508075688775

このように、引数のクラスによって適切なメソッドが呼び出され、ポリモーフィズムが働いていることがわかります。もしも、ポリモーフィズムを利用せずにプログラムすると、distance の中でインスタンスのクラスをチェックしなければいけません。インスタンスのクラスを調べるには関数 class-of を使います。

class-of object

class-of は引数 object が属するクラスを返します。class-of を使って distance を書き換えると、リスト 3 のようになります。

リスト 3 : ポリモーフィズムを使わない distance

(define (distance p1 p2)
  (cond ((and (eq? (class-of p1) <point>)
              (eq? (class-of p2) <point>))
	 (let ((dx (- (get-x p1) (get-x p2)))
	       (dy (- (get-y p1) (get-y p2))))
	   (sqrt (+ (* dx dx) (* dy dy)))))
        ((and (eq? (class-of p1) <point3d>)
              (eq? (class-of p2) <point3d>))
	 (let ((dx (- (get-x p1) (get-x p2)))
	       (dy (- (get-y p1) (get-y p2)))
	       (dz (- (get-z p1) (get-z p2))))
	   (sqrt (+ (* dx dx) (* dy dy) (* dz dz)))))
        (else (error "distance -- oops!"))))
gosh> (distance a b)
14.142135623730951
gosh> (distance c d)
17.320508075688775
gosh> (distance a d)
*** ERROR: distance -- oops!

このように、クラスはインスタンスのデータ型を表す識別子としての役割もあります。Gauche の場合、既存のデータ型に対応するクラスがあらかじめ定義されているので、ここでは「クラスはデータ型を表す」と考えてもらってもかまいません。

distance は 2 つのデータを扱うだけなので、プログラムはそれほど複雑にはなりません。しかし、たくさんのデータを扱うようになると、それだけプログラムは複雑になります。とくに新しいデータを追加する場合、プログラムの内部でデータの種別をチェックしている箇所をすべて調べて、そこに新しい処理を追加しなければいけません。プログラムの規模が大きくなると、修正箇所を調べるだけでも大変です。

ところが、ポリモーフィズムを使ってプログラムを作ると、新しいデータを追加するにしても、そのデータを表すクラスとメソッドを定義するだけでいいのです。あとは Gauche がインスタンスに合わせて適切なメソッドを呼び出してくれます。オブジェクト指向では、オブジェクトをひとつの部品として扱います。新しい部品を追加するにしても、今までの部品を修正せずにそのまま使えた方が便利です。ポリモーフィズムはオブジェクト指向に必須の機能なのです。

●既存のデータ型とクラスの関係

Gauche の場合、Gauche (Scheme) で定義されているデータ型に対応するクラスが用意されています。既存のデータ型のクラスも関数 class-of で求めることができます。実際に class-of でクラスを求めると次のようになります。

gosh> (class-of 1)
#<class <integer>>
gosh> (class-of 1.0)
#<class <real>>
gosh> (class-of "abc")
#<class <string>>
gosh> (class-of 'a)
#<class <symbol>>
gosh> (class-of '(a b c d))
#<class <pair>>
gosh> (class-of '())
#<class <null>>
gosh> (class-of #(1 2 3))
#<class <vector>>

これらのクラスは define-method で引数のクラスを指定するときに使用することができます。ただし、make でインスタンスを生成することはできません。このほかにも、数を表すクラス <number> やリストを表すクラス <list> などがあります。


Copyright (C) 2010 Makoto Hiroi
All rights reserved.

[ PrevPage | Scheme | NextPage ]