オブジェクト指向言語#
教科書5章 パラダイム 講義2回分
序論#
%%{init: {'flowchart': {'curve': 'linear', 'nodeSpacing': 40, 'rankSpacing': 40, 'padding': 5}}}%%
graph TD
classDef focus stroke-width:4px
パラダイム --> 命令型;
パラダイム --> 宣言型;
命令型 -->|ほぼ同義| 手続き型;
命令型 --> オブジェクト指向:::focus;
宣言型 --> 関数型;
宣言型 --> 論理型;
命令型言語に続いて,オブジェクト指向言語(OO言語)と呼ばれるプログラミングパラダイムについて解説する.オブジェクト指向は命令型言語から派生したパラダイムであり,共通する要素も多い.命令型とOOは背反するパラダイムではないという点に注意せよ.
OO言語では,命令型言語の不足点を補うために数々の抽象的なアイデアが取り込まれている.つかみ取りにくい部分もあるが,基本的には具体的なソースコードに落とし込むことが可能である.すなわち抽象⇔具象の変換が容易である.理解しにくいと感じたらソースコードと対応させて考えよ.
なお,本ページでは教科書の流れを無視して説明するので注意すること.また説明題材にはOO言語の代表としてJavaを用いるが,その他のOO言語にも適用できる内容である.本ページの主題はJavaではなくOO言語である点に注意せよ.
本ページでは犬 is a 動物
や車 has a タイヤ
のような,OO言語の典型例題は極力用いない.過度に抽象化されており不毛と感じるためである.プログラミングの際に多用する配列やスタックなどのデータ構造を例題に説明を進める.
OO言語のねらい#
OO言語の本質的なねらいについて説明する.説明題材として,文字列中に含まれる小文字を全て大文字に変換するtoUpper()
関数を考える("Hello#123"
→ "HELLO#123"
).
なお説明していない構文が多数含まれるがここでの本質ではない.プログラム中の細かな構文ではなく,OO言語が実現したい最も本質的な要素を解説している点に注意せよ.
まず命令型言語の再確認から始める.命令型言語でも述べたとおり,命令型言語は代入文を基本的な機械操作とし,条件分岐や繰り返しによってその実行順序を制御する.命令型言語の一つであるC言語を用いたtoUpper()
の実装は次のとおりである.
void main() {
char s[30] = "Hello#123";
for (int i = 0; i < sizeof(s); i++) { // 全文字について
if (s[i] >= 'a' && s[i] <= 'z') { // もし小文字なら
s[i] += 'A' - 'a'; // 大文字に変換する
}
}
printf("%s\n", s); // HELLO#123
}
手続き型言語では,命令型言語の処理の一部を部品化(手続き化・関数化)する.部品化によってシステム全体を簡単な機能に分解し,その複雑さを軽減する.これは分割統治の考えに従う.
void toUpper(char s[30]) { // 大文字化の処理を手続き化
for (int i = 0; i < sizeof(s); i++) {
if (s[i] >= 'a' && s[i] <= 'z') {
s[i] += 'A' - 'a';
}
}
}
void main() {
char s[30] = "Hello#123";
toUpper(s); // 手続きを呼び出す
printf("%s\n", s); // HELLO#123
}
手続き型言語における部品化という考えは単純かつ有効だったが,開発システムの複雑化に伴いその限界が見えてきた.そこで,より強力な分割統治を実現する手段としてOO言語が生まれた.
手続きからオブジェクトへ#
OO言語の根本的な考えは,システムのあらゆる対象をオブジェクトとして捉える点にある.オブジェクトは,データとデータに対する操作から構成される概念である.
OO言語では,オブジェクトを中心としてあらゆる計算対象をモデル化する.例えば数値や文字列などの基本的な値だけでなく,ユーザ情報や画面の構成部品など,システム内のあらゆる要素はオブジェクトとして扱われる.
OO言語の一つであるJavaではtoUpper()
は次のように実装できる(1).
- クラスという語を多用するが厳密な定義は無視せよ.後に説明する. ひとまずオブジェクト=クラスと解釈して問題ない.
public class CharSequence { // データと操作を持つクラス
char[] s; // データ(文字の集合)
public void toUpper() { // 操作(大文字化)
for (int i = 0; i < s.length; i++) {
if (s[i] >= 'a' && s[i] <= 'z') {
s[i] += 'A' - 'a';
}
}
}
public CharSequence(String s) { // コンストラクタ.無視してOK
this.s = s.toCharArray();
}
public String toString() { // データの確認用.無視してOK
return new String(s);
}
}
まずtoUpper()
の最も本質的な操作(文字a-zの大文字への変換)の処理は大きく変わっていない.代入を基本操作として,繰り返し文と条件文によって実行順序を制御している.
最も重要な特徴は,文字列データs
と操作toUpper()
がCharSequence
というクラスにまとめられている点である.文字列データと文字列に対する操作を一つの単位にまとめ上げることで,命令型言語ではできなかったより強力な分割統治が可能となる.
またmain()
側にも大きな違いがある.命令型言語ではtoUpper(s)
のように,手続きへの引数としてデータを指定していた.命令型言語ではデータと操作は独立して定義されるため,プログラマがどの操作にどのデータを割り当てるかを指定する必要がある.他方,OO言語ではcharseq.toUpper()
のように,オブジェクトcharseq
を主体として適用したい操作を指定する.操作とデータが一体化しているため,操作を呼び出す際にはCharSequence
クラスの持つ操作の中から選択すればよい.
別の言い方をすれば,命令型言語は「操作」を中心に問題を分割する一方で,OO言語は「オブジェクト」を中心に問題を分割する.例えばシステムを実現する場合,2つのパラダイムは以下のような問題分割の違いが生まれる.
パラダイム | 問題分割の単位 | 分割されたシステムの要素 |
---|---|---|
命令型言語 | 操作 | ユーザの新規作成,ユーザのログイン, データの新規作成,データの変更,データの削除, DBへの登録,DBからの参照 |
OO言語 | オブジェクト | ユーザオブジェクト, データオブジェクト, DBオブジェクト |
OO言語は「データと操作は常に一体化せよ」という指針をプログラマに与えている.この指針は単純でありプログラマの直感にも従っている.例えば,toUpper()
は文字データに特化した処理であり,文字データとtoUpper()
を一体化するという考えは自然である.
Quiz
文字列クラスが持つべき操作,すなわち文字列データと一体化されるべき操作を列挙せよ.先のtoUpper()
はその一つである.
Answer
toUpper()
toLower()
replace(..)
substring(..)
length()
など.
Javaには文字列を扱うString
クラスが標準で用意されている.String
クラスが持つ操作はAPIドキュメントを確認せよ.なお先のCharSequence
クラスはString
クラスの劣化版であり,自作する必要は一切ない.
Note
オブジェクト指向の各種機能はC言語の構造体や関数ポインタによって再現可能である.OO言語はこの機能を言語仕様として定義しているといえる.
なお,OO言語を使えば自然とオブジェクト指向を実現できるわけではない点に注意せよ.特にJavaはC言語を源流としており,命令型言語にオブジェクト指向の機能を追加した言語である.そのため,Javaを使ってオブジェクト指向の考えに則らないプログラムを記述することもできてしまう.以下のtoUpper()
実装はJavaを使った非オブジェクト指向のプログラムである.
public class MainNotOOP {
public static void toUpper(char s[]) { // ただの手続き
for (int i = 0; i < s.length; i++) {
if (s[i] >= 'a' && s[i] <= 'z') {
s[i] += 'A' - 'a';
}
}
}
public static void main(String[] args) {
char s[] = "Hello#123".toCharArray();
toUpper(s);
System.out.println(s); // HELLO#123
}
}
OO言語にはオブジェクト指向を実践する強制力があるわけではない.オブジェクト指向とは制約ではなく,プログラミングにおける分割統治の指針であると認識せよ.
Note
C言語を使えば自然と構造化プログラミングを実現できるわけではないのと同じである.
- 構造化プログラミングのためには,意識的にgoto文を排除する必要がある
- OOプログラミングのためには,意識的にオブジェクトを設計する必要がある
データ構造の隠蔽化#
続いてOO言語の数ある利点の一つであるデータ構造の隠蔽化について取り上げる.説明題材としてスタックの実装を考える.このスタックはint
型の要素のみを格納する.
public class Main {
public static void main(String[] args) {
Stack stack = new ArrayStack(); // オブジェクト生成(1)
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack); // [1, 2, 3]
try {
stack.pop();
stack.pop();
stack.pop();
stack.pop();
System.out.println(stack); // [1, 2]
} catch(Exception e) {
e.printStackTrace();
}
stack.push(5);
System.out.println(stack); // [1, 2, 5]
}
}
- 返り値の型は
ArrayStack
よりもStack
が適切である.理由は後述するので今は気にしなくて良い.
public class ArrayStack implements Stack { // 配列を使ったスタック実装
private int[] elements;
private int sp; // スタックポインタ
public void push(int element) {
elements[sp++] = element;
}
public int pop() {
return elements[--sp];
}
public ArrayStack() { // コンストラクタ.無視してOK
sp = 0;
elements = new int[10];
}
public String toString() { // データの確認用.無視してOK
int[] buffer = new int[sp];
System.arraycopy(elements, 0, buffer, 0, sp);
return Arrays.toString(buffer);
}
}
public class ListStack implements Stack { // Listを使ったスタック実装
private LinkedList<Integer> elements;
public void push(int element) {
elements.add(element);
}
public int pop() {
return elements.removeLast();
}
public ListStack() { // コンストラクタ.無視してOK
elements = new LinkedList<>();
}
public String toString() { // データの確認用.無視してOK
return elements.toString();
}
}
Stack
はスタックの仕様(インタフェース)であり,スタックというオブジェクトが満たすべき以下の条件を定義している.
- 必ず2つの操作
push()
pop()
を持つ push()
の返り値と引数(シグネチャ)はvoid push(int)
であるpop()
の返り値と引数(シグネチャ)はint pop()
である
このインタフェースを実装したクラスがArrayStack
とListStack
である.この2つはスタックを保持する内部データ構造が異なる.
ここで最も重要な点は,ArrayStack
とListStack
のどちらを使っても,Main
クラスでの呼び出し方法や結果が一切変わらない点である.スタック内部のデータ構造がクラス内に隠蔽化され,操作が抽象化されているといえる.よってスタックを利用するプログラマが知るべき情報はStack
インタフェースのみであり,具体的な実装の中身(この場合ArrayStack
とListStack
)は意識する必要がない.
このようなデータ構造と操作が一体化された型は抽象データ型(abstract data type)とも呼ばれ,オブジェクト指向の登場前から存在していた.OO言語は抽象データ型をオブジェクトという概念に発展させ,言語仕様として抽象データ型の優れた性質(データの隠蔽化等)を実現している.
Note
Stringクラスを利用する際に,Stringクラスのソースコードを確認しなくてよいのと同じである.利用者が知るべき情報は,そのクラスの公開メソッドでのみありAPIドキュメント等を調べれば十分である.
Quiz
ArrayStack
クラスにはバグがある.どのようなバグでどうすれば発現するか?
Answer
push()
とpop()
時にスタックサイズの上限・下限チェックを行っていない.よって,例えばpop()
しすぎると配列外参照を起こす.
public static void main(String[] args) {
Stack stack = new ArrayStack();
stack.push(1);
stack.push(2);
stack.pop();
stack.pop();
stack.pop(); // !!!
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 10
at stack.ArrayStack.pop(ArrayStack.java:12)
at stack.Main.main(Main.java:13)
以下のようなチェックが必要.
代表的なOO言語#
C言語やPascal,Fortranなど様々な命令型言語が存在するのと同様に,OO言語も多種多様である.OO言語は純粋型とハイブリッド型の2種類に大別できる.前者は当初からOO言語として設計されたものであり,後者は非OO言語にオブジェクト指向の機能を拡張したものである.
タイプ | 言語 |
---|---|
純粋なOO言語 | Smalltalk,Eiffel,Self,Rubyなど |
ハイブリッド型OO言語 | Java,C++,Simula,Objective-Cなど |
再掲:プログラミング言語の歴史
©1
JavaはC言語から派生したハイブリッド言語であり,構文的な類似点はかなり多い.C言語の既習者はJavaに移行しやすいともいえるが,純粋なOO言語ではないため混乱を招く要因もある.特にプリミティブ型と参照型の違いには要注意である.前者はオブジェクトであるが後者はオブジェクトではない.全てをオブジェクトとせよという指針を掲げながら,オブジェクトでない要素を持っている言語である.この点は後述する.
オブジェクトの実現方法としてはJavaが採用するクラスベースだけでなく,プロトタイプベースと呼ばれる方法も存在している(1).現在はクラスベースが主流なため,このページではクラスベースを前提に説明を進める.
- JavaScriptがプロトタイプベースである.
OO言語の機能:構築#
データと操作の一体化を実現させるために,OO言語には様々な言語機能が搭載されている.以降ではこの言語機能を2パートに分解して紹介する.
- オブジェクト構築のための機能や概念(本節)
- オブジェクト再利用のための機能や概念(次節)
まずはオブジェクトの構築について説明する.数多くの要素が含まれるのでよく学習すること.説明題材には先のArrayStackクラスを用いる.ArrayStack
のクラス図は次のとおりである.適宜参照せよ.
classDiagram
class Stack
class ArrayStack
class ListStack
Stack <|.. ArrayStack : implements
Stack <|.. ListStack : implements
class Stack {
<<interface>>
+push(int) void
+pop() int
}
クラスとインスタンス#
クラスとはインスタンスを作るための型や設計図のことであり,インスタンスはクラスから生成された実体である.
インスタンスはnew
演算子によって生成される.この生成操作をインスタンス化(instantiation)と呼ぶ.当然ながら単一のクラスから複数のインスタンスを作ることもできる.
Note
「オブジェクト」という言葉は多義的なので注意せよ.Javaにおいてはオブジェクトはインスタンスと同じ意味で用いられるが,OO言語の文脈ではクラスとして表現しようとしている概念そのものをオブジェクトと呼ぶこともある.混乱回避のため,以降ではオブジェクトという語を用いずに,クラスとインスタンスという語を用いて説明を進める.
フィールドとメソッド#
クラスの構成要素はデータと操作である.クラス内のデータをフィールド,クラス内の操作をメソッドと呼ぶ.C言語における変数・関数とほぼ同義かつ同じ宣言の記法であり,クラス内部で宣言された変数がフィールド,クラス内部で宣言された関数がメソッドと解釈して良い.
public class ArrayStack implements Stack {
private int[] elements; // フィールド
private int sp; // フィールド
public void push(int element) {...} // メソッド
public int pop() {...} // メソッド
...
Note
クラスやフィールドの命名規則には共通認識が存在する.クラス名は先頭と区切りを大文字(SomeClass
),フィールド名とメソッド名は区切りのみ大文字(someField
)である.言語仕様ではないので従わなくてもコンパイルはできるが,極力従うこと.
一種のマナーである.守らなくても機能的には問題ないが無作法で美しくはない.
これらのルールはコーディングスタイルと呼ばれる.GoogleがJavaのコーディングスタイルガイド(和訳版)を公開しているので,一度目を通すと良い.
クラスを利用する際には,クラスからインスタンス化(実体化)を行い,生成されたインスタンスに対してメソッド呼び出し等の操作を行う.
Stack stack = new ArrayStack();
stack.push(1); // インスタンスに対する操作(メソッド呼び出し)
stack.push(2); // インスタンスに対する操作(メソッド呼び出し)
インスタンス化の際には,インスタンスの初期化作業としてクラス内の特別なメソッドが呼び出される.この初期化用メソッドをコンストラクタと呼ぶ.コンストラクタはクラス名と同じ名前,かつ返り値の宣言がないという特殊な形式で宣言される.
public class ArrayStack implements Stack {
...
public ArrayStack() { // コンストラクタ
sp = 0; // フィールドの初期化
elements = new int[10]; // フィールドの初期化
}
コンストラクタ宣言に引数を加えることで,クラス利用者がインスタンスの初期値を指定することもできる.
public class ArrayStack implements Stack {
...
public ArrayStack(int max) { // コンストラクタ引数にmaxを追加
sp = 0;
elements = new int[max]; // 初期化時に配列の最大サイズを指定
}
フィールドにstatic
修飾子を付与すると,staticフィールド(静的フィールド)という特殊なフィールドになる.staticではない一般的なフィールド(non-staticフィールド)はインスタンスごとに保持されるデータであるが,staticフィールドはクラス共通で保持される.これまでのArrayStack
クラスの全てのフィールドは,インスタンスごとに保持されるnon-staticフィールドであった.
public class ArrayStack implements Stack {
private int[] elements; // non-staticフィールド
private int sp; // non-staticフィールド
non-staticフィールドは単にフィールドと呼ばれることが多い.
Javaの整数値を格納するIntegerクラスの場合,MAX_VALUE
やMIN_VALUE
などがstaticフィールドとして保持されている.
Integer.MAX_VALUE
はInteger
クラスで全体で共通する不動の値であり,インスタンスごとに保持する必要がないためstaticフィールドが適している.staticフィールドはクラス名.フィールド名
のように,インスタンスではなくクラスに対して直接フィールドを呼び出す.
フィールドと同様,メソッドにもstatic
修飾を付与できる.個々のインスタンスには依存せず,クラスで共通するメソッドはstaticメソッドとするのが適切である.
Note
OO言語に慣れないうちはstatic化は最小限にすることを推奨する.特にstaticフィールドはクラス共通のグローバル変数のように扱えるため,オブジェクト指向の原則(隠蔽化やカプセル化)を容易に破壊してしまう.
どうしてもstaticフィールドを利用したいならfinal
修飾子をつけて不変にすると良い.可変なstaticフィールドを作りたくなったら,設計そのものの悪さを疑うべきである.
まとめると以下の通りとなる.
宣言方法 | 理由 | |
---|---|---|
int |
OK | 一般的なフィールドの利用方法 |
final int |
OK | ↑よりもgood.再書き換え禁止が望ましい |
static int |
NG | クラス共通かつ書き換え可なので予期できない |
static final int |
OK | クラス共通だが書き換え不可なのでOK |
《宿題1》#
Homework 約30分
答え
Java標準のStringクラスのソースコードを確認し,String
内に含まれる以下4種類をそれぞれ4個以上列挙せよ.
- フィールド(non-static)
- staticフィールド
- メソッド(non-static)
- staticメソッド
以下の書式をコピーして回答せよ.
回答時にはソースコード内の宣言文1行をコピペすればよい.つまりフィールドの場合は宣言文,メソッドの場合はシグネチャ文が答えである.例えば,String.javaのこの一文は通常のフィールドであり,次のように答えれば良い.
プリミティブ型と参照型#
代表的なOO言語で述べたようにJavaは純粋なOO言語ではなく,全てをオブジェクトとせよというOO言語の原則に則っていない部分がある.その一つはプリミティブ型(値型,基本データ型)と参照型の存在である.プリミティブ型は値を直接格納する原始的なデータ型であり,boolean
やint
long
などの8種類(1)が該当する.参照型は値ではなく参照を格納する型であり,プリミティブ型8種を除く全てのインスタンスが該当する.
boolean
byte
char
short
int
float
long
double
の8種類.
int i = 0; // iはプリミティブ型
boolean b = true; // bはプリミティブ型
long l = 1000 * 1000; // lはプリミティブ型
Stack stack = new ArrayStack(); // stackは参照型
String str = new String("hi"); // strは参照型
int[] nums = new int[]{1, 2}; // numsは参照型(配列も参照型)
参照型はインスタンスのことであり,データと操作が一体となったオブジェクトと呼べる.一方,プリミティブ型は単なるデータでありオブジェクトではない.つまり操作を持たない.
Javaはオブジェクトではないプリミティブ型が存在するため,純粋なOO言語ではない.
なお,全てのプリミティブ型には対応する参照型が存在する.これをラッパークラスと呼ぶ.例えば,プリミティブ型int
には参照型のラッパークラスInteger
クラスが存在している.
Integer i = new Integer(5);
i.toString(); // iは参照型なのでメソッドを呼び出せる
Integer j = 10; // ラッパークラスには対応するプリミティブ型を直接代入できる
Double d = 5.0;
メモリの側面で考えると,参照型は命令型言語におけるポインタ(間接参照)と同義である.C言語ではアドレス演算子&
や参照外し演算子*
を用いて明示的にポインタの操作を指定していたが,Javaではそのような演算子を指定しない.プリミティブ型なら常に値が,参照型なら常に参照が渡される.
int i = 0; // 適当なプリミティブ型要素
x.method1(i); // 値のコピーが渡される
// method1()の中で仮引数を書き換えてもこのiには影響がない
Stack stack = new ArrayStack(); // 適当な参照型要素
x.method2(stack); // 実引数に参照型を指定すると自動的に参照が渡される
// よってmethod2()の中でstackを書き換えると
// このstackの中身も書き換わる
命令型で説明した引数の結合方法という意味ではJavaはC言語と同様である.すなわち原則としては値渡しのみが利用可能だが,参照を値渡しすることで参照渡しを実現している.
プリミティブ型が存在する理由は実行効率にある.参照型はヒープ領域にその実体データが格納され,それに対する参照をスタック領域やレジスタに格納する.よって実体データにアクセスするには,一度参照を経由する必要がある(スタックを見てヒープを見る).対し,プリミティブ型はスタック領域やレジスタに値そのものを直接格納する.よって参照を経由することなく値を直接取り出すことができる.int
などの小さく多用される原始的なデータに対しては,参照ではなく値を直接格納することで実行効率の改善を狙っている.
再掲:ヒープとスタック(命令型言語)
┏━━━━━━━━━━━━━━━┓
┃コード,大域変数,静的変数 ┃
┣━━━━━━━━━━━━━━━┫
┃ヒープ(動的データ) ┃
┣━━━━━━━━━━━━━━━┫
┃ ↓ ┃ // 下に向かって格納
┃ ┃
┃ ↑ ┃ // 上に向かって格納(積み上げ)
┣━━━━━━━━━━━━━━━┫
┃実行時スタック(ローカル変数)┃
┗━━━━━━━━━━━━━━━┛
基本的にスタックの方がアクセスが高速である.データ構造が単純であり,その操作はpush
pop
のみで実現できるためである.
より詳細なJavaのメモリ領域についてはJava言語で説明する.
カプセル化と情報隠蔽#
オブジェクト指向では互いに関連するデータと操作を一つの部品・モジュールとしてまとめ,一部のインタフェースを通じてのみ外部と通信させる.この概念をカプセル化(encapsulation)と呼ぶ.カプセル化はオブジェクト指向の最も基本的な指針であるといえる.
クラス内部のデータ構造は,カプセル化と可視性の制御によってクラスの利用者から隠蔽される.この考えを情報隠蔽(information hiding)と呼ぶ.
public class ArrayStack implements Stack {
private int[] elements; // 隠蔽される
private int sp; // 隠蔽される
public void push(int element) {...} // 公開される
public int pop() {...} // 公開される
この例では,スタックに対する操作(void push(int)
とint pop()
)のみを公開し,データ構造(int[] elements
とint sp
)を情報隠蔽している.スタックを利用するうえでは,スタック内部がどのようなデータ構造によって実現されているかは一切気にする必要はない.
カプセル化と情報隠蔽の結果,以下のような分割統治が可能となる.
- クラスの利用者:クラスの公開部分(インタフェース)のみに着目すればよい
- クラスの提供者:クラスの非公開部分を自由に変更できる
Quiz
Java標準の文字列クラスString
の内部データはbyte[]
で保持されている.実際に一文字は何バイトで管理されているか考えよ.
String s = new String("Hello日本語abcß");
System.out.println(s); // Hello日本語abcß
System.out.println(s.toUpperCase()); // HELLO日本語ABCSS
Answer
一文字は2バイトか4バイト.文字列データはUTF-16形式で保持されている.https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/lang/String.html
Stringは、補助文字をサロゲート・ペアで表現するUTF-16形式の文字列を表します(詳細は、Characterクラスの「Unicode文字表現」セクションを参照)。 charコード単位を参照するインデックス値です。したがって、補助文字はStringの2つの位置を使用します。
文字列の内部構造は実は複雑である.
ここで重要なことは,クラスの内部構造を全く知らなくても利用は可能という点である.データ構造や手段(メソッドの内部)を利用者から隠蔽化する.利用者は使い方だけ分かれば良い.
カプセル化と情報隠蔽のためには,適切な可視性(visibility)の制御が必要である.可視性とはメソッドやフィールドをどの範囲まで公開するかを表す性質であり,アクセス修飾子(public
やprivate
)の付与によって制御される.先のArrayStack
の例では,可視性が最も広いpublic
と最も狭いprivate
の2種類のアクセス修飾子を用いていたが,Javaには合計4種類(1)のアクセス飾子が存在している.
- 可視性の広さ順に,
public
protected
なし
private
の4つ.なし
はpackage-privateとも呼ばれる.
極端なケースとしては全てのメソッドとフィールドをpublic
にすれば,誰でもどこからでもアクセス可能になる.この場合,カプセル化や情報隠蔽の利点が損なわれてしまう.オブジェクト指向を実践する,すなわちカプセル化を実現するためには可視性を可能な限り狭く設定すべきである.
Note
狭い可視性を選ぶ理由は,変数のスコープを限定することで考慮すべき事項を限定化していたのと同じ理屈である.
再掲:スコープと存続範囲(命令型言語)
int global1; // global1
int f(int param) { // | param
... // | ↓
} // |
int global2 // | global2
int main() { // | |
int local1; // | | local1
if (...) { // | | |
...
スコープが存在することで,その範囲内で利用できる変数が限定的になり,考慮すべき事項を減らすことができる....
ジェネリクス#
次のクイズから考えよ.
Quiz
これまでのArrayStack
は配列要素としてint
型のみが利用可能であった.int
以外の様々な型を格納できるスタックを作るにはどうすればよいか?
public class ArrayStack implements Stack {
private int[] elements; // int型の配列しか扱えない
public void push(int e) {...} // 当然push()できるのはint型のみ
public int pop() {...} // 当然pop()の結果もint型のみ
Answer
答え1(非現実的):#
型ごとにスタッククラスを用意する.IntArrayStack
,DoubleArrayStack
,StringArrayStack
など.ただし無限の可能性がある.
答え2(理想):#
型をパラメタ化する.
public class ArrayStack implements Stack {
private E[] elements; // E型の配列
public void push(E e) {...} // E型をpush
public E pop() {...} // E型をpop
public ArrayStack(E) { // コンストラクタで型指定
... // ※構文的には正しくないので注意
答え3(番外):#
Object
型で受け付ける.型付け言語の利点を損なう方法である.詳細は略.
Pythonのような動的型付け言語では,型を明示しないため上記クイズのような問題は発生しないが,Javaのような静的型付け言語では問題となる.このような問題への対策として,型を限定せずに型を抽象化・パラメタ化するジェネリクス(総称型)と呼ばれる言語機能がある.ジェネリクスによりインスタンス化の際に具体的な型を指定できる.
public class Main {
public static void main(String[] args) {
// ↓ここで配列内部の型がIntegerであることを指定する
Stack<Integer> stack1 = new ArrayStack<>();
stack1.push(1); // ↑ここは省略可
stack1.push(2);
stack1.push(3);
System.out.println(stack1); // [1, 2, 3]
// ↓ここで配列内部の型がStringであることを指定する
Stack<String> stack2 = new ArrayStack<>();
stack2.push("hello"); // ↑ここは省略可
stack2.push("world");
System.out.println(stack2); // [hello, world]
}
}
パラメタ化された型(上記の場合E
)を型パラメタと呼ぶ.型パラメタとして指定可能な型は参照型のみでありプリミティブ型は指定できない.よって上記の例ではプリミティブ型int
ではなく,ラッパークラスであるInteger
型を指定している.
OO言語の機能:再利用#
ここまで説明した言語機能によって,オブジェクト指向の基本単位であるオブジェクトの構築が可能となった.
オブジェクト指向ではデータと操作の一体化による分割統治だけでなく,既存オブジェクトの再利用による効率的なプログラミングの実現も重要な鍵である.本節では既存オブジェクトを再利用するための言語機能を紹介する.
説明題材にはJavaに標準搭載されたArrayListクラスを用いる.ArrayList
は可変長配列を表すクラスであり,Javaの集合系クラスの中では最も汎用的でポピュラーである.これまでのArrayStack
とは全く別のクラスである点に注意せよ.スタックではなくアレイ(配列)である.
public class Main {
public static void main(String[] args) {
ArrayList<Integer> list = new ArrayList<>();
list.add(2);
list.add(3);
list.add(1);
list.add(1);
list.add(2);
System.out.println(list); // [2, 3, 1, 1, 2]
}
}
ArrayList
クラスでは以下のようなpublicメソッドが利用できる.
- 要素の追加
add(value)
- 要素の取得
get(i)
- 要素の更新
set(i, value)
- 配列サイズの取得
size()
- 配列が空かどうかの確認
isEmpty()
- ...
継承#
既存オブジェクトを再利用するための重要な仕組みの一つが継承(inheritance)である.継承を用いることで親クラス(スーパークラス)の持つ全てのフィールドとメソッドが,子クラス(サブクラス)に引き継がれる.
一例として,ArrayList
を継承したShuffleArrayList
クラスを考える.ShuffleArrayList
では配列内の要素をランダムに並び替えることができる.
public class ShuffleArrayList<E> extends ArrayList<E> { // 継承
private Random rand = new Random(); // シャッフル用乱数
public void shuffle() { // 配列内をシャッフル
for (int i = 0; i < size(); i++) {
swap(i, rand.nextInt(i + 1)); // swap()を呼び出し
}
}
private void swap(int i1, int i2) { // 2要素のswap
E e1 = get(i1); // 親クラスのget()が使える
E e2 = get(i2);
set(i1, e2); // 親クラスのset()も使える
set(i2, e1);
}
}
ShuffleArrayList
クラスはextends
キーワードによりArrayList
クラスの継承を宣言している.これにより親クラスArrayList
の全フィールドと全メソッドが,子クラスShuffleArrayList
で利用可能となる(1).よってget()
やset()
などの配列として必要なメソッドを自身で記述せずとも,追加したいshuffle()
メソッドのみを記述すればよい.当然get()
やset()
などの親クラスのメソッドは子クラス内部でも利用可能であり,swap()
メソッド内で利用している.単純に考えると,親クラスの全メソッド・全フィールドが,子クラスにコピーされているとみなしても良い.
- 可視性
private
のフィールドとメソッドは子クラスからも見えない(使えない).
さらに可変長配列の本質である集合要素がどのようなデータ構造で管理されているかも意識する必要がない.親クラスが集合要素を適切に管理しているため,子クラスはその責任から解放されている.
フィールドrand
とメソッドswap()
の可視性は最も狭いprivate
としている.これらはshuffle()
メソッドのための内部要素であり,外部に公開する必要がなく情報隠蔽すべき対象である.
継承関係はクラス図を用いて以下のように表現できる.
classDiagram
class ArrayList
class ShuffleArrayList
ArrayList <|-- ShuffleArrayList : extends
class ArrayList {
+add()
+get()
+...()
}
class ShuffleArrayList {
+shuffle()
}
さらにShuffleArrayList
の拡張を考える.現状のShuffleArrayList
では要素追加add()
の後にshuffle()
を呼び出すことで配列内部がランダムに入れ替えられる.もし,配列内要素の並びが常にランダムである状態を保持したいのであれば,ShuffleArrayList
クラスに以下のような追記を行えば良い.
public class ShuffleArrayList<E> extends ArrayList<E> { // 継承
...
public boolean add(E e) { // 親クラスのadd()を上書き
boolean f = super.add(e); // まず親クラスのadd()を直接呼び出す
shuffle(); // その後で並び替える
return f; // 親add()時の要素追加の成否を返す
}
}
add()
のたびに常にshuffle()
が呼び出されるため,常に内部要素の並びはランダムとなる.このような親クラスのメソッドの上書きをオーバーライドと呼ぶ.
extends
キーワードを用いた継承はis-a関係とも呼ばれる.すなわち「子クラスは親クラスである」とみなせる.上記の例では「ShuffleArrayList
はArrayList
である」という関係が成り立つ.ShuffleArrayList
は機能的にはshuffle()
が追加されているものの,ArrayList
としての特性や性質は一切損なわれていない.
Note
大阪大学 is a 大学
は成り立つ.阪大は様々な特徴を持つが大学であることに変わりはない.
継承を用いたプログラミングでは,親クラスに対する差分を子クラスに記述する.これにより同一機能を実装する際に記述すべきコード量が減少する.結果的に生産性の向上やバグの軽減などの恩恵が生まれる.
インタフェースと抽象クラス#
継承されることを前提とした特殊なクラスが2つ存在する.インタフェースと抽象クラスである.
インタフェースはメソッドのシグネチャ(メソッド名・返り値・引数)のみを定義する仕組みであり,具体的な実装や手段を分離する.例えば,Java標準のArrayList
クラスはCollectionインタフェースを継承している.Collection
インタフェースは,集合を表すクラスが持つべき共通のメソッドシグネチャを定義している(1).
- 集合クラスには様々な具体実装がありうる. 配列を用いた
ArrayList
やリンク構造を用いたLinkedList
,木構造を用いたTreeSet
など様々である.Stack
も集合クラスの一種である.
クラス図では以下のように表現される.
classDiagram
class Collection
class ArrayList
<<interface>> Collection
Collection <|-- ArrayList : implements
インタフェースを継承する際はextends
(拡張)ではなくimplements
(実装)というキーワードを用いる.両者に本質的な差はない.implements
もextends
もどちらも継承である.
インタフェースを用いることでメソッド内部に記述される具体的な手段の分離が可能になる.データ構造の隠蔽化でも述べたとおり,クラスの利用者はインタフェースのみを知っていれば良く,その内部の手段を意識する必要はない.
インタフェースにはメソッドのシグネチャのみを記載するが,それだけでは利便性が低い状況がある.例えば,集合クラスにおいて集合が空かを判定するisEmpty()
メソッドは以下のように実装できる.
集合の要素数size()
の取得方法自体は子クラスのデータ構造に依存するが,isEmpty()
の処理内容はあらゆる集合クラスに共通する.よって,isEmpty()
は子クラスではなく親クラス側で定義したいが,親クラスであるインタフェースにはメソッド内部を記述することはできない.
このような場合には抽象クラスを用いる.JavaではAbstractCollectionという抽象クラスが設けられており,実際にisEmpty()
メソッドは中身このクラスで定義されている.
public abstract class AbstractCollection<E> implements Collection<E> {
...
public boolean isEmpty() {
return size() == 0;
}
...
}
抽象クラスの宣言時はabstract
修飾子を付与する.この例では抽象クラスがインタフェースを継承しているが,インタフェースの継承は抽象クラスの必須要件ではない.何も継承しない抽象クラスもあり得る.
まとめるとArrayListのような集合クラスは以下のような継承関係を持っている(1).
- これでも実際より単純化しているので注意.
classDiagram
class Collection
class AbstractCollection
class ArrayList
Collection <|-- AbstractCollection : implements
AbstractCollection <|-- ArrayList : extends
AbstractCollection <|-- LinkedList : extends
AbstractCollection <|-- HashSet : extends
class Collection {
<<interface>>
}
class AbstractCollection {
<<abstract>>
}
抽象クラスとインタフェースは継承を前提としており,それ単体をインスタンス化することはできない.またインタフェースは仕様であるという性質上,可視性を記述する必要はない.全メソッドがpublic
であることが自明なためである.クラスと抽象クラス,インタフェースの特性の違いは次の表のとおりである.
クラス | 抽象クラス | インタフェース | |
---|---|---|---|
具体例 | ArrayList * |
AbstractCollection * |
Collection * |
宣言時の予約語 | class |
abstract class |
interface |
継承時の宣言 | extends |
extends |
implements |
インスタンス生成 | 可能 | 不可能 | 不可能 |
フィールド | 利用できる | 部分的に利用できる | 利用できない |
コンストラクタ宣言 | 可能 | 可能 | 不可能 |
抽象度 | 最低 | 中 | 最高 |
Note
多くの概念や要素を説明してきたが「暗記」は本質ではない.個々の概念を「理解」できるよう努力せよ.理解の伴わない暗記には意味がない.
本節ではOOにおける良い設計の一例として,ArrayListクラス周辺のクラス階層について説明した.集合クラスが様々な観点で抽象化されている点を認識せよ.
ポリモーフィズム#
継承を用いることでポリモーフィズム(Polymorphism,多態性,多様性,多相性)と呼ばれる概念を実現することができる.ポリモーフィズムとは,異なる処理を持つインスタンスを全く同一の操作で扱うことができる性質のことである.
Java標準の集合クラスを用いてポリモーフィズムの振る舞いを説明する.集合クラスとしては,単純な配列を表すArrayList
以外にも,重複を許さないLinkedHashSet
,重複を許さずさらに要素を自動ソートするHashSet
などがある.
インスタンス化以外の操作は全く同一であるにも関わらず,その出力結果はクラスによって異なっている.共通のインタフェースadd()
時に,各集合クラスの特性(ソートする・重複を許す等)に応じて適切な要素追加が行われる.
Note
RPGのようなゲームにおいて,全敵キャラクターに攻撃をさせる場合は以下のように記述できる.
共通の性質を持つクラスのインタフェースが統一化されており,その呼び出しが容易で可読性も高い.また,追加で新たな敵種類を実装しても呼び出し側の変更は不要であり,拡張が容易である.ポリモーフィズムが使えない場合,以下のような呼び出しが必要となる.
委譲とコンポジション#
継承とは異なる再利用の仕組みとして委譲(delegation)とコンポジション(composition)がある.
委譲とは,処理を自分自身で行わず別のオブジェクトに任せるという概念である.例として,カードゲームにおけるデッキ(山札)クラスを考える.単純化のためにカードは数字のみ(スートなし)で1~8までの8枚のみとする.
デッキ内のデータはint
型配列として表現できるため,既存のArrayList
クラスをうまく再利用したい.すなわち配列に関する基本操作をArrayList
クラスに委譲したい.
public static void main(String[] args) {
Deck deck = new Deck();
System.out.println(deck); // [1, 2, 3, 4, 5, 6, 7, 8]
System.out.println(deck.size()); // 8
deck.shuffle();
System.out.println(deck); // [2, 5, 3, 6, 8, 4, 1, 7]
System.out.println(deck.deal()); // 7
System.out.println(deck.deal()); // 1
System.out.println(deck); // [2, 5, 3, 6, 8, 4]
System.out.println(deck.size()); // 6
}
public class Deck {
private List<Integer> deck = new ArrayList<>();
public Deck() { // コンストラクタ
for (int i = 0; i < 8; i++) {
deck.add(i + 1); // 1~8のカードを格納(委譲)
}
}
public int size() { // デッキの残り枚数を確認
return deck.size(); // deckにサイズを聞く(委譲)
}
public int deal() { // 1枚カードを抜く
int lastIndex = size() - 1;
int card = deck.get(lastIndex); // 配列から1枚取り出す(委譲)
deck.remove(lastIndex); // 配列から削除(委譲)
return card;
}
public void shuffle() { // デッキをかき混ぜる
...
}
Deck
クラスは内部フィールドとしてArrayList<Integer>
を保持しており,デッキデータの格納先として利用している.デッキ内の残り枚数を取得するsize()
メソッドでは,deck.size()
を呼び出して処理を委譲している.Deck
の開発者は配列の基本操作を全てArrayList
クラスへ委譲させ,デッキクラスとして必要なメソッド,つまりdeal()
やshuffle()
等のみを実装すれば良い.このように委譲を用いることで,既存クラスの再利用が可能となる.
あるオブジェクトAが異なる別のオブジェクトXとYに分解できるとき,AをXとYのコンポジション(合成集約)という.これをhas-a関係と呼ぶ.A has a X
とA has a Y
が成り立つ.
Deck
クラスは内部にArrayList
を抱えており,コンポジションの関係にある.Deck has a ArrayList
である.
classDiagram
direction LR
Deck o-- ArrayList
Deck
クラスはArrayList
クラスの合成集約である.コンポジションはオブジェクト間の関係のことであり,委譲は処理を別オブジェクトに依頼する行為である.委譲とコンポジションは同一視されることも多いが,厳密には異なる概念である点に注意せよ.
Quiz
次の2項がis-a関係(継承)かhas-a関係(コンポジション)かを考えよ.
- 犬と動物
- 車とタイヤ
- リンゴとフルーツ
- 学生と学籍番号
Answer
- 犬 is a 動物
- 車 has a タイヤ
- リンゴ is a フルーツ
- 学生 has a 学籍番号
オブジェクトを設計する際は,物事間の関係をよく考えて設計すること.
継承 vs コンポジション#
最後に,継承とコンポジションの違いについて考える.継承とコンポジションはいずれも再利用のための仕組みであり,共通点が多数ある.継承はコンポジションで代替が可能であり,その逆も可能である.例えば,先のDeck
クラスはArrayList
のコンポジションではなく継承でも実現できる.この場合,Main
側の呼び出しやその結果も変わらない.
public class Deck {
private List<Integer> deck = new ArrayList<>();
public Deck() {
for (int i = 0; i < 8; i++) {
deck.add(i + 1);
}
}
public int size() {
return deck.size();
}
public int deal() {
int lastIndex = size() - 1;
int card = deck.get(lastIndex);
deck.remove(lastIndex);
return card;
}
public void shuffle() {
...
}
privateフィールドとして保持していたdeck
が継承によって自分自身となる.よって,継承ではdeck
フィールドの宣言が不要となり,またdeck
インスタンスのメソッド呼び出しdeck.
も不要となる.
is-a関係とhas-a関係で考えると次のとおりである.
Deck
is anArrayList
\(~\):継承(is-a関係)Deck
has anArrayList
:コンポジション(has-a関係)
継承に適した問題と委譲に適した問題があるので,適切に使い分ける必要がある.基本的にはis-a関係が成立している特殊な場合のみ継承を用い,そうでない場合は委譲を用いると良い.Deck
クラスの場合,Deck
はArrayList
を利用しているだけであり,「Deck
is an ArrayList
」という構造は適切ではない.よって委譲が適すると判断できる.
Note
近年のプログラミングでは,継承ではなくコンポジションを優先せよ,"composition over inheritance" という考えが広まっている2.この点については講義最後のその他の言語で解説する.
オブジェクト指向が登場して50年以上経つが,現在でも上記のような議論がなされている.この事実から,継承とコンポジションの適切な使い分けは容易ではないことがうかがえる.これらを適切に使い分けるためには,理論の理解だけではなく実践と経験が必須である.
継承とコンポジションを考える際は,まずはその性質の理解から始めると良い.適切な使い分けや効果的な利用方法は,後に身につければよい.
《宿題2》#
Homework 約30分
答え
まずは適当なJavaの実行環境を用意せよ.その環境下で次のHomework
クラスのmain()
メソッドを実行し,その出力結果を確認せよ.123
という文字が6行得られるはずである.
public class Homework {
public static void main(String[] args) {
A aaa = new A();
B bbb = new B();
C ccc = new C();
System.out.println(aaa.x()); // 123
System.out.println(aaa.y()); // 123
System.out.println(bbb.x()); // 123
System.out.println(bbb.y()); // 123
System.out.println(ccc.x()); // 123
System.out.println(ccc.y()); // 123
}
}
class A {
public String x() { return "123"; }
public String y() { return "123"; }
}
class B extends A {
public String x() { return "123"; }
}
class C {
private A a = new A();
public String x() { return "123"; }
public String y() { return a.y(); }
}
Q1.#
出力結果とともに,その出力文字列が最終的にどのクラスのどのメソッドから得られたかを答えよ.例えば,a()
→b()
のような呼び出し系列に対してはb()
が答えである.また委譲によって得られた出力結果には「委譲」と一言を添えよ.
Q2.#
次の2項がis-a関係(継承)がhas-a関係(コンポジション)か答えよ.
- クラス
B
とクラスA
- クラス
C
とクラスA
以下の書式をコピーして回答せよ.