対象読者
以下に飽き飽きした方
- 「猫クラス、犬クラス」等のしょーもない説明
- オブジェクト指向の抽象的で難しい用語
結論
クラスはオブジェクト指向の土台である。しかし、クラスを使う動機やメリットを理解することは容易ではない。抽象的で難しい用語が溢れていて、初心者は圧倒されてしまうからだ。カプセル化、継承、ポリモーフィズム、単一責任の原則、デザインパターン、UMLなど、挙げれば切りがない。
また、オブジェクト指向なフレームワークを使ってはいても、クラスを適切に使えていない人も多い。
確かに、難解でしゃらくさい用語を理解することは大事だ。しかし、そんなことは棚上げしても、クラスを使うことには十分なメリットがある。
そこで本記事では、クラスの最低限のメリットを解説することで、オブジェクト指向へ馴染んでいくための基礎を提供する。
本記事を要約すると、クラスの最低限のメリットとは、グローバルな変数と関数をユーザ定義型としてまとめることで、影響範囲をインスタンススコープへ制限し、プログラマの認識負荷を下げ、コードを理解しやすくできることだ。
何を言ってるのか分からないと思う。なので、まずはクラスは手続き型プログラミングの限界を超えるために生まれたことを見る。
手続き型プログラミングという自然なアプローチ
オブジェクト指向は難しい。なぜなら、プログラムに対する考え方が不自然だからだ。
自然に考えるならば、プログラムは処理を順番に並べることで表現することになる。例えば、学校の運動会のプログラムも時刻順にイベントを箇条書きする。このようなプログラミングの捉え方を「手続き型プログラミング」という。
何の前提知識もない初心者は十中八九、手続き型プログラミングによってコーディングを行う。私もそうだった。「まずはこういうデータを用意して、その値がこうならば、こう処理して…」みたいな感じで手続きを箇条書きしていくことでプログラムを作っていく。
手続き型プログラミングで主役となる構成要素は以下だ。
- グローバル変数
- 関数(サブルーチン、プロシージャとも言う)
- 条件分岐(if文など)
- 繰り返し文(for文、while文など)
main文はこれらを駆使して、いろんな処理を順次実行していくことでプログラムは目的を達成することになる。
手続き型プログラミングの限界 グローバル変数
しかし、手続き型プログラミングでは、main文がグローバル変数や実効順序、(骨組みとなる)条件分岐、関数の呼び出し等を全て管理しなければならない。
故に、main文にはたくさんの記述が必要となり、理解することが難しくなる。その結果、バグが生まれやすく、機能追加、変更が難しくなってしまう。
例えば、main文にグローバル変数の宣言がダーッと並んでいるのを見て萎えた経験がある方は多いのではなかろうか。
main文の複雑さを緩和するために、まとまった処理を切り出す(分離する)仕組みが関数である。関数を適切に使用すれば、ある程度、理解しやすいコードを書くことができる。関数による機能分割はクラス以前の基礎だ(基礎だからといって簡単ではない)。
こうして、main文は多くの細かい関数の組み合わせへと還元できる。しかし、ここに相反する要求が生じる。すなわち、
- 関数を分離・独立させたい
- 関数を組み合わせたい
という矛盾だ。これを解決(妥協)するためにグローバル変数が必要となる。
複数の関数がプログラムの目標達成に向けて協力するためには、関数同士が情報をやり取りする必要がある。その橋渡しをグローバル変数によって実現できる。しかし、グローバル変数は、
- 関数が外部の影響を受けてしまい、独立性を損ねてしまう
- どこからでも値を変更できてしまうため、値の推移が理解しづらくなる
故に、コードが理解しづらくなる。これが手続き型プログラミングの限界だ。
クラスとは関連する変数と関数をまとめる仕組み
以上から、関数の独立性を保つことは難しいことが分かった。そこで、オブジェクト指向は別のアプローチをとる。すなわち、関数を独立させるのではなく、データと関数のまとまりを独立させることを目指す。
実は、グローバル変数と関数には関連があることが多い。言い換えると、複数の関数は同じグローバル変数を共有する。グローバル変数は複数の関数に橋渡しするのだから当然だ。
グローバル変数とそれに関連する関数をまとめて独立させることができれば、グローバル変数の問題を回避しつつも、関数間でのデータのやり取りが可能となる。
クラスとは、グローバル変数と関数を関連によって分類(class)し、まとめるための仕組みだ。大量のファイルを関連性によってフォルダに分類することで管理しやすくなることと同様に、クラスによる分類もプログラムを理解しやすくしてくれる。一度に認識しなければいけない情報量を抑えること(抽象化)がプログラミングの様々なテクニックのエッセンスだ(詳細はこちら)。
ちなみに、クラス内にまとめられた変数や関数のことを総称して「メンバ」といったり、
- 変数は「プロパティ、フィールド、メンバ変数、インスタンス変数」
- 関数は「メソッド、メンバ関数、インスタンス関数」
などと呼ばれる。
ここまでの説明では、クラスは手続き型と一線を画するもののように見えると思う。しかし、手続き型には既に「構造体」というクラスの前身となる仕組みがあった。
クラスとは構造体の拡張
構造体とは、複数の変数(データ型)を1つにまとめるためのユーザ定義型だ。C言語やFortranのような言語に導入されている古い技術だ。
例えば、座標ならば、x,y,zのような3つの実数(浮動小数点)をまとめて定義できる。
このように、構造体によって関連するグローバル変数を分類することで、一度に認識しなければならない情報量を減らすことができる。
変数だけでなく、関数もまとめられるように構造体を拡張したものがクラスだ。
クラスを実現するためには以下の3つの構成要素が必要となる。
- グローバル変数と関数を詰め込められる入れ物(インスタンススコープ)
- その入れ物を指示するための名前(クラス名)
- クラスをメモリ上に配置する機能(インスタンス化)
他にも継承やアクセス修飾子(private/public)などの重要な要素もあるが、それらを棚上げすれば、クラスは構造体とほぼ同じだ。
関数を持たせることができる構造体(=クラス)の何がそんなに嬉しいのか。以下の具体例を通して手続き型とクラスの違いを見ていく。
クラスの定義
まずは手続き型のコードを示す。
例えば、三角形の計算に関連する関数があるとする。グローバルな座標p1,p2,p3は以下の3つの関数と関連している。
//グローバル変数
p1 = x
p2 = y
p3 = z
function calc_triangle_area(){
global p1, p2, p3 //グローバル変数を関数内で使用
//面積の計算
}
function calc_degree(){
global p1, p2, p3
//p1,p2の線分とp1,p3の線分が成す角度の計算
}
function calc_normal(){
global p1, p2, p3
//三角形の法線ベクトルの計算
}
これをクラスで書き換えてみる。
class Triangle {
constructor(p1, p2, p3){
this.p1 = p1
this.p2 = p2
this.p3 = p3
}
calc_area(){
//this.p1, this.p2, this.p3を用いて面積を計算する
}
calc_degree(){/*省略*/}
calc_normal(){/*省略*/}
}
単に関数などをclassの括弧内に閉じ込めただけに見える。この書き換えの何が嬉しいのだろうか。両者の三角形関連の操作の例を比較する。
クラスのインスタンス化・コンストラクタ
まだクラス定義によって新たなデータ型を作成しただけなので、クラスを動作させることはできない。そこで、以下のような記述をまず行う必要がある。
//インスタンス化 tri = Triangle(x, y, z)
文字や数値のような基本データ型は、値を指定してメモリ上に配置(allocate)できる。このメモリの場所の情報(ポインタ)を持ったものが変数である。
構造体は複数の値をまとめてメモリ上に配置できるユーザ定義型だ。しかし、定義を記述しただけではメモリ上へは配置されない。基本データ型と同様に、値を指定してメモリ上に配置しなければならない。
クラスもユーザ定義型であり、クラスにメモリを割り当てることを「インスタンス化」という。allocateされたメモリの場所へのポインタを「インスタンス」という。
インスタンス化する際の動作を定義したメソッドのことを特別に「コンストラクタ(constructor)」という。
上記のTriangleクラスのコンストラクタでは、引数で与えられた実数を自身(this)へセットしていることを表している。
インスタンス化してはじめて、メソッドを動作させることができる。
area = tri.calc_area()
deg = tri.calc_degree()
norm = tri.calc_normal()
アクセスを制限するインスタンススコープ
上では、しれっとメソッドを呼び出したが、一般にメンバには次のようにアクセスできる。
インスタンス.メンバ名
もともとグローバル変数はどこからでもアクセスできた。しかし、メンバはインスタンスを通じて(ピリオドで繋いで)のアクセスしかできなくなった。
名前の有効範囲を「スコープ」と呼ぶ。グローバル変数は至るところからアクセスできる(グローバルスコープ)。メンバはインスタンス内でのみ有効である(インスタンスコープ)。
インスタンスによってスコープが制限されることで、より厳密に分類を表現できる。グローバルスコープにある要素はプログラマがそれぞれ用途を識別して利用するしかなかった。グローバルな名前がたくさんあるのは、大量のファイルから探し物をするのと同様に大変だ。
一方、メンバはアクセスが制限されているため、識別対象を減らすことができ、コードが理解しやすくなる。特定のフォルダの中身だけ見れば済むのと同じだ。
手続き型では同じ接頭辞などによる命名で同じグループを表現したりする。しかし、プログラマの認識負荷を下げることができない。
たくさん作れる
一方、手続きバージョンのmain文は以下だ。
//グローバル変数
p1 = x
p2 = y
p3 = z
area = calc_triangle_area()
deg = calc_degree()
norm = calc_normal()
ここで、別の三角形について計算したくなったとしよう。この時、クラスのメリットが発揮される。
手続きバージョンの場合、グローバル変数と関数をさらに定義しなればならない。
//追加のグローバル変数
p1_add = x2
p2_add = y2
p3_add = z2
//追加の関数
function calc_triangle_area2(){
global p1, p2, p3 //グローバル変数を関数内で使用
//面積の計算
}
function calc_degree2(){
global p1, p2, p3
//p1,p2の線分とp1,p3の線分が成す角度の計算
}
function calc_normal2(){
global p1, p2, p3
//三角形の法線ベクトルの計算
}
area = calc_triangle_area()
deg = calc_degree()
norm = calc_normal()
//追加の処理
area2 = calc_triangle_area2()
deg2 = calc_degree2()
norm2 = calc_normal2()
ぶっちゃけこの程度の処理ならば、引数を導入すれば、関数をさらに定義する必要はない。しかし、現場レベルのより複雑な手続き型コードでは、このような(関数がグローバル変数による依存性によって再利用できなくなる)状況はよく見られる。
さらに3つ目の三角形を計算しなければならなくなったら、定義を追加しなければならない。めんどくさい。。。
しかも、めんどくさいで済むならまだマシだ。それぞれの三角形ごとに定義した関数には重複部分があり、修正が必要になったら、各関数を修正しなければならず、修正漏れが出る危険が生じる。
一方、クラスバージョンはかなりスマートに書けるし、三角形の数が増えても問題なく対処できる。
//追加のインスタンス化
tri2 = Triangle(x2, y2, z2)
area = tri.calc_area()
deg = tri.calc_degree()
norm = tri.calc_normal()
//追加の処理
area = tri2.calc_area()
deg = tri2.calc_degree()
norm = tri2.calc_normal()
とある記事では、クラスの本質は上記のように「たくさん作れる(マルチプルインスタンス)」ことだと解説されている。かなりのメリットであることは間違いない。しかし、たくさん作れるという特性はデータ型一般のものであって、クラス固有のものではない。やはりクラスの本質とは、構造体の拡張によって、グローバルな要素を分類できることであって、その結果、マルチプルインスタンスが可能になっただけの話だ。
さらにクラスを有効に使うために
以上はクラスの最低限のメリットであり、構造体に毛が生えた程度のことしか解説していない。とはいっても大きなメリットだ。まずはこのメリットを意識しながらクラスを使っていくことをオブジェクト指向への足掛かりとしていただければ幸いだ。
しかし本記事では棚上げしたが、クラスには他にも様々な機能やテクニックがある。
- アクセス修飾子
- カプセル化
- ポリモーフィズム
- 継承
- 委譲(コンポジション)
- デザインパターン
- SOLIDの原則
- テスト駆動開発
- ドメイン駆動設計
いろいろあるが、全てクラスによる分類をより鋭く行っていく方法に過ぎない。いずれにせよ、これらの基本はクラスの分類する機能にある。
コメント