カプセル化
スタックの実装
スタックはLIFO(Last In First Out)型のデータ構造で、末尾へのデータの追加(push)と末尾からのデータの取出し(pop)の二種類の操作ができます。これをプログラムで実現するためには、データを格納する配列とスタックに格納されている要素数(=次に格納する要素の添え字)を管理する方法が考えられます。 プログラムにすると以下のようになります。
void setup() {
int[] data = new int[100];
int top = 0;
int value;
// push
data[top] = 10;
top += 1;
// push
data[top] = 20;
top += 1;
// pop
top -= 1;
value = data[top];
println(value);
// push
data[top] = 30;
top += 1;
// pop
top -= 1;
value = data[top];
println(value);
// pop
top -= 1;
value = data[top];
println(value);
}
実行結果
20
30
10
スタックの構造を維持するためには、pushとpopの操作で data と top の2つの変数をセットで扱う必要があることがわかります。
これをクラスを用いて一体化してみましょう。「Stack.java」の名前でタブを追加して、以下のようなプログラムになります。
Stack.java
class Stack {
int[] data = new int[100];
int top = 0;
}
void setup() {
Stack stack = new Stack();
int value;
// push
stack.data[stack.top] = 10;
stack.top += 1;
// push
stack.data[stack.top] = 20;
stack.top += 1;
// pop
stack.top -= 1;
value = stack.data[stack.top];
println(value);
// push
stack.data[stack.top] = 30;
stack.top += 1;
// pop
stack.top -= 1;
value = stack.data[stack.top];
println(value);
// pop
stack.top -= 1;
value = stack.data[stack.top];
println(value);
}
実行結果
20
30
10
data と top を Stack クラスのフィールドにすることでスタックに関する情報をひとまとめにしました。これによって、例えば次のようにStackクラスを利用した関数を作りやすくなるでしょう。
void pushTenTimes(Stack stack, int value) {
for (int i = 0; i < 10; ++i) {
stack.data[stack.top] = value;
stack.top += 1;
}
}
ここまでのプログラムには、次の暗黙の「規約」が存在します。
- pushするときは、
dataのtop番目の要素に値を書き込み、topの値を1増やす - popするときは、
topの値を1減らし、dataのtop番目の要素を取り出す - スタックはpushとpopの操作のみができる
もしプログラミングの途中でこの規約を忘れてしまった場合は、以下のようにスタックとしての機能を果たさないバグを生じます。
void setup() {
Stack stack = new Stack();
int value;
// push
stack.data[stack.top] = 10;
stack.top += 1;
// push
stack.data[stack.top] = 20;
// stack.top += 1; // topを増やすのを忘れた
// pop
stack.top -= 1;
value = stack.data[stack.top];
println(value);
// push
stack.data[stack.top] = 30;
stack.top += 1;
// pop
// stack.top -= 1; // topを減らすのを忘れた
value = stack.data[stack.top];
println(value);
// pop
stack.top -= 1;
value = stack.data[stack.top];
println(value);
}
実行結果
10
20
30
これを解消するために、pushとpopの操作を関数でまとめます。プログラムは以下のようになります。
Stack.java
class Stack {
int[] data = new int[100];
int top = 0;
}
void push(Stack stack, int value) {
stack.data[stack.top] = value;
stack.top += 1;
}
int pop(Stack stack) {
stack.top -= 1;
return stack.data[stack.top];
}
void setup() {
Stack stack = new Stack();
int value;
// push
push(stack, 10);
// push
push(stack, 20);
// pop
value = pop(stack);
println(value);
// push
push(stack, 30);
// pop
value = pop(stack);
println(value);
// pop
value = pop(stack);
println(value);
}
実行結果
20
30
10
これによって、push 関数と pop 関数を用いる限りはスタックに対する規約を維持することができます。しかしまだ、次のように意図的にスタックの規約を外部から破ることができます。
Stack.java
class Stack {
int[] data = new int[100];
int top = 0;
}
void push(Stack stack, int value) {
stack.data[stack.top] = value;
stack.top += 1;
}
int pop(Stack stack) {
stack.top -= 1;
return stack.data[stack.top];
}
void setup() {
Stack stack = new Stack();
int value;
// push
push(stack, 10);
// push
push(stack, 20);
// スタックの先頭要素を書き換える
stack.data[0] = 30;
// pop
value = pop(stack);
println(value);
// pop
value = pop(stack);
println(value);
}
実行結果
20
30
これは、スタックのpushとpopのみができるという規約を破っています。特に大規模なプログラムや複数人での開発では、どこでルールが破られているかわからないことがバグにつながったりします。
スタックに関する規約が常に守られるようにするために、pushとpopの操作を Stack クラスのメソッドとして実装し、data と top を private フィールドにすることで Stack クラスの外部からのアクセスを禁止します。プログラムは以下のようになります。
Stack.java
class Stack {
private int[] data = new int[100];
private int top = 0;
void push(int value) {
this.data[this.top] = value;
this.top += 1;
}
int pop() {
this.top -= 1;
return this.data[this.top];
}
}
void setup() {
Stack stack = new Stack();
int value;
// push
stack.push(10);
// push
stack.push(20);
// pop
value = stack.pop();
println(value);
// push
stack.push(30);
// pop
value = stack.pop();
println(value);
// pop
value = stack.pop();
println(value);
}
実行結果
20
30
10
Stack.java の外部から Stack クラスの data や top フィールドにアクセスしようとするとコンパイルエラーになることが確認できます。これによって、スタックの規約を破ることが不可能になります。
カプセル化とJavaのクラス構文
スタックの実装を通した例のように、データと手続きを一体化することでデータが守らないといけない規約を破らせないことを カプセル化 と呼びます。カプセル化によって、プログラマーが気を付けて守らないといけなかったルール(規約)を、守らなければコンパイルや実行ができないようにして破ることができないように制限します。制約のないプログラミングでは自由度が高い反面、明文化されていないルールを気を付けて守る必要があります。オブジェクト指向のような設計方法の醍醐味は、プログラムにあえて制約を課すことでルールを明文化し、ルールを破ったプログラムを書くことができないようにすることです。
Processing(Java)でオブジェクト指向プログラミングを行うには、class キーワードを使用して クラス を定義します。
クラスの中には、状態を表す フィールド、振る舞いを表す メソッド、そして初期化処理を行うための コンストラクタ を記述します。
フィールド
クラス内で定義する変数のことを フィールド と呼びます。フィールドには宣言と同時に初期値を代入(初期化)することができます。
初期化を省略した場合は、整数型なら 0、真理値型なら false などのデフォルト値が自動的に割り当てられます。
Counter.pde
class Counter {
int count = 10; // フィールドの宣言と初期化
}
void setup() {
Counter c = new Counter(); // インスタンスの生成
println(c.count);
}
実行結果
10
メソッド
クラスが行う処理振る舞いを メソッド と呼びます。 メソッドを呼び出すことで、フィールドの値を読み書きしたり、特定の計算を行わせたりすることができます。
Counter.pde
class Counter {
int count = 0;
// メソッドの定義
void increment() {
count += 1;
}
}
void setup() {
Counter c = new Counter();
c.increment(); // メソッドの呼び出し
println(c.count);
}
実行結果
1
コンストラクタ
インスタンスを new キーワードで生成する際に、一度だけ自動的に呼び出される特別なメソッドを コンストラクタ と呼びます。
コンストラクタはクラス名と全く同じ名前にし、戻り値の型を記述しないというルールがあります。
主にフィールドの初期設定を行うために使用されます。
Counter.pde
class Counter {
int count;
// コンストラクタの定義(引数を受け取れる)
Counter(int initialCount) {
count = initialCount;
}
}
void setup() {
Counter c = new Counter(100); // コンストラクタに値を渡す
println(c.count);
}
実行結果
100
アクセス修飾子
フィールドやメソッドへのアクセスを制限するために、アクセス修飾子 を使用します。 これによって、外部から勝手にデータを書き換えられないように保護することができます。
public: クラスの外部から自由にアクセスできます。private: そのクラスの内部からのみアクセス可能です。外部からアクセスしようとするとコンパイルエラーになります。- 修飾子を省略した場合: 同一のパッケージ内からアクセス可能です。Processingでは、同じスケッチ内にある全てのタブは同じパッケージとして扱われるため、実質的にスケッチ内のどこからでもアクセスできます。
public を指定すると、インスタンスを操作する側から自由にフィールドの値を変更できてしまいます。
Account.pde
class Account {
public int balance = 1000; // publicフィールド
}
void setup() {
Account a = new Account();
a.balance = 5000; // 外部から直接アクセスして書き換え可能
println(a.balance);
}
実行結果
5000
private を指定すると外部からの直接アクセスが禁止されるため、専用のメソッドを通じてのみデータを操作させることができます。
Account.pde
class Account {
private int balance = 1000; // privateフィールド
// 外部から操作するためのpublicメソッド
public void deposit(int amount) {
if (amount > 0) {
this.balance += amount;
}
}
public int getBalance() {
return this.balance;
}
}
void setup() {
Account a = new Account();
// a.balance = 5000; // エラー:外部からは直接アクセスできない
a.deposit(500); // メソッド経由で安全に操作する
println(a.getBalance());
}
実行結果
1500
修飾子を省略した場合は、Processing の同一スケッチ内(同じフォルダ内の各タブ)からであれば自由にアクセスできます。
Account.pde
class Account {
int balance = 1000; // 修飾子なし
}
void setup() {
Account a = new Account();
a.balance = 2000; // アクセス可能
println(a.balance);
}
実行結果
2000
カプセル化の基本は、フィールドを private にして外部から隠し、適切なメソッド(public)を通してのみデータを操作できるようにすることです。
ProcessingのIDEでは、同じタブの中に複数のクラスを書いた場合、private を指定していても同一ファイル内の他のクラスからアクセスできてしまいます。private によるアクセス制限を正しく機能させるには、別のタブとしてクラスを作成する必要があります。
本講義の例題でも、クラスは別のタブに作成することを推奨します。
演習
演習1
カプセル化の目的やメリットを踏まえ、プログラムにおいてカプセル化が必要になる具体的な場面を挙げ、その理由を述べなさい。
演習2
以下の Stack クラスを利用して、逆ポーランド記法の数式を計算するプログラムを作成しなさい。
Stack.java
class Stack {
private int[] data = new int[100];
private int top = 0;
void push(int value) {
this.data[this.top] = value;
this.top += 1;
}
int pop() {
this.top -= 1;
return this.data[this.top];
}
}
数式は文字列として与えられ、数値は一桁の整数値(0から9)のみを扱うものとする。
例えば、文字列 "23+5*" は (2 + 3) * 5 を計算することを意味し、その結果である 25 を出力しなさい。
演算子は +(加算), -(減算), *(乗算), /(除算) の4種類のみを扱うものとする。
対象とする文字列は100文字以内であると仮定してよい。
演習3
以下の setup 関数が正しく動作するように、Queue クラスを設計しなさい。
キューはFIFO(First In First Out)型のデータ構造であり、先入れ先出しでデータを処理するものである。
Queue クラスは、以下の2つのメソッドを持つように実装しなさい。
enqueue(int value): キューの末尾に整数値を追加する。dequeue(): キューの先頭から整数値を取り出し、その値を戻り値として返す。 なお、キューの溢れやデータが空の状態での取り出しについては考慮しなくてもよい。
void setup() {
Queue queue = new Queue();
queue.enqueue(10);
queue.enqueue(20);
queue.enqueue(30);
println(queue.dequeue());
println(queue.dequeue());
queue.enqueue(40);
println(queue.dequeue());
println(queue.dequeue());
}
実行結果
10
20
30
40
演習4
以下の資料を参考に、 BinaryHeap(二分ヒープ)クラスを設計しなさい。
https://basic-programming2.vdslab.jp/docs/04function-design/heapsort
BinaryHeap クラスは、以下の2つの public メソッドを持つ必要がある。
push(int value): ヒープに整数値を追加し、ヒープの条件を満たすように要素の並びを更新する。pop(): ヒープから最小値を取り出し、残った要素でヒープを再構成し、取り出した最小値を戻り値として返す。
また、コンストラクタでは引数としてヒープが保持できる要素の最大数(配列のサイズ)を受け取るようにしなさい。
実装したクラスを用いて、以下の setup 関数(ヒープソート)が正しく動作することを確認しなさい。
void setup() {
int[] values = {20, 10, 30, 5, 15, 25};
BinaryHeap heap = new BinaryHeap(values.length);
// すべての要素をヒープに追加する
for (int v : values) {
heap.push(v);
}
// 最小値から順番に取り出して表示する(昇順ソート)
for (int i = 0; i < values.length; i++) {
println(heap.pop());
}
}
実行結果
5
10
15
20
25
30
演習の解答例
演習1の解答例
場面: 日付を扱うデータ(年月日)
理由: 年・月・日のそれぞれを自由に整数値として入力できると、「13月」や「32日」のような存在しない日付が設定されてしまう可能性があるため。 カプセル化を行い、値をチェックする専用のメソッドを通すように制限することで、常に正しい日付の状態を保つことができる。
演習2の解答例
カプセル化された Stack クラスを利用して、逆ポーランド記法の数式を計算するプログラムの実装例は以下の通りである。
文字列から1文字ずつ取り出し、数字であればスタックに積み、演算子であればスタックから2つの値を取り出して計算結果を再び積むことで、数式を評価している。
rpn.pde
void setup() {
String expr = "23+5*";
Stack stack = new Stack();
for (int i = 0; i < expr.length(); i++) {
char c = expr.charAt(i);
if (c >= '0' && c <= '9') {
stack.push(c - '0');
} else {
int b = stack.pop();
int a = stack.pop();
if (c == '+') {
stack.push(a + b);
} else if (c == '-') {
stack.push(a - b);
} else if (c == '*') {
stack.push(a * b);
} else if (c == '/') {
stack.push(a / b);
}
}
}
println(stack.pop());
}
実行結果
25
演習3の解答例
キューの動作を実現する Queue クラスの実装例は以下の通りである。
配列を用いてデータを保持し、取り出す位置(head)と追加する位置(tail)をそれぞれ管理することで、先入れ先出しの機能を実現している。
Queue.java
class Queue {
private int[] data = new int[100];
private int head = 0;
private int tail = 0;
void enqueue(int value) {
this.data[this.tail] = value;
this.tail += 1;
}
int dequeue() {
int value = this.data[this.head];
this.head += 1;
return value;
}
}
演習4の解答例
最小値を効率的に取り出すことができる BinaryHeap クラスの実装例は以下の通りである。
内部で shiftup と shiftdown という再構成処理をメソッドとして定義している。
さらにインデックス計算や存在確認のための補助メソッドを private で定義することで、複雑な条件判定を整理し、ヒープの内部構造を適切に隠蔽している。
BinaryHeap.pde
class BinaryHeap {
private int[] data;
private int count = 0;
BinaryHeap(int size) {
this.data = new int[size];
}
public void push(int value) {
this.data[this.count] = value;
this.shiftup(this.count);
this.count += 1;
}
public int pop() {
int value = this.data[0];
this.count -= 1;
this.data[0] = this.data[this.count];
this.shiftdown(0);
return value;
}
private void shiftup(int index) {
while (this.hasParent(index)) {
int p = this.parent(index);
if (this.data[index] < this.data[p]) {
this.swap(index, p);
index = p;
} else {
break;
}
}
}
private void shiftdown(int index) {
while (this.hasChild(index, this.count)) {
int child = this.left(index);
if (this.hasRight(index, this.count) && this.data[this.right(index)] < this.data[child]) {
child = this.right(index);
}
if (this.data[child] < this.data[index]) {
this.swap(index, child);
index = child;
} else {
break;
}
}
}
private void swap(int i, int j) {
int tmp = this.data[i];
this.data[i] = this.data[j];
this.data[j] = tmp;
}
private int parent(int i) {
return (i - 1) / 2;
}
private int left(int i) {
return 2 * i + 1;
}
private int right(int i) {
return 2 * i + 2;
}
private boolean isRoot(int i) {
return i == 0;
}
private boolean hasParent(int i) {
return !this.isRoot(i);
}
private boolean hasLeft(int i, int n) {
return this.left(i) < n;
}
private boolean hasRight(int i, int n) {
return this.right(i) < n;
}
private boolean hasChild(int i, int n) {
return this.hasLeft(i, n);
}
}