![]()
![]() 続・サン認定Javaプログラマ試験反省 ( 20050125 ) 昨日に引き続いて記憶に残っている限りでサン認定Javaプログラマ試験問題を振り返ってみたい(繰り返しになるが実際に出題された問題と同じである保証はまったくない)。Javaの言語仕様の基本的なところを突いてくる引っ掛け問題は、Javaを勉強し始めたばかりの人にとっては間違いやすいのだろう(と言いながら僕も正解できていたかどうかは不明)。
public class InheritTest {
public static void main(String[] args) {
Parent p = new Child();
Child c1 = new Child();
Child c2 = (Child)p;
p.val = 1;
c1.val = 1;
System.out.println(p.calc(1));
System.out.println(p == c1);
System.out.println(p == c2);
System.out.println(p.equals(c1));
System.out.println(p.equals(c2));
}
}
class Parent {
int val = 0;
public int calc(int i) {
return i+1;
}
}
class Child extends Parent {
public int calc(int i) {
return i+10;
}
}
オブジェクトの継承関係と、オブジェクトどうしが「等しい」かどうかという二つのポイントを一つの問題に押し込んだのが上の例題だ。このプログラムを実行したら何が表示されるだろうか。正解は「11/false/true/false/true」(ここでは/は改行の意味とする)。 まず最初のcalcメソッドの呼び出し結果だが、Parentクラスを拡張したChildクラスのインスタンス(オブジェクト)は、Parentクラス型の変数「p」に代入することができる。しかしParentクラス型の変数に代入しても中身はChildクラスであることには変わりないので、「p.calc(1)」というcalcメソッドの呼び出しは、Childクラスで定義されているcalcメソッドを呼び出す。したがって結果は「2」ではなく「11」になる。 仮に同じ行で「((Parent)p).calc(1)」と無理やりキャストしてParentクラス扱いにしても結果は同じだ。子クラスのインスタンスは「Parent p = new Child()」という具合にキャストなしで親クラス型の変数に代入できるし、「p.calc(1)」も「((Parent)p).calc(1)」も同じくChildクラスのメソッドを実行するというのでは、何のためにキャストがあるのかという話になるが、キャストの役割は「Child c2 = (Child)p」の行でわかる。 このキャストを削除して「Child c2 = p」と書くとコンパイルエラーになる。クラス名をキャストするのは、親クラス型の変数に代入した子クラスのインスタンスが「親クラスの顔をしているけれど、実は子クラスのインスタンスなんですよ」ということを示すのが目的のようだ。例をあげると、Vectorのような配列の機能をもつオブジェクトにStringクラスのインスタンスを次々に追加して、取り出すときにStringでキャストして「Objectクラスの顔をしているけれど、実はStringクラスなんですよ」というような場合だ。 残り4行の「System.out.println」文では、オブジェクト同士が「等しい」とはどういうことかが問題になっている。「p」と「c1」はともにChildクラスで、かつ、変数「val」が同じ値「1」を持っている。その他の違いは全くない。したがって日常生活の常識的には「p」と「c1」は同じだと言ってもよさそうだが、モノは別物なので「p==c1」は「偽(false)」になる。同じモデル、同じ色の車が2台並んでいるのと同じことだ。 しかし次の「p==c2」は「真(true)」になる。これは変数(p, c1, c2など)はオブジェクト(=何らかのクラスのインスタンス)への参照を持つというJavaの仕様による。「Child c2 = (Child)p」という文の結果、変数「c2」は変数「p」が指し示すモノと同じモノを参照するようになる。先ほどは「ChildクラスのインスタンスをParentクラス型の変数に代入する」という具合に「代入する」という表現を使ったが、これはあまり正確でない。「代入する」と言うと、まるで原物の複製を作って新しい箱の中に投げ込むような印象になるが、実際には原物は原物のままで、今までその原物と「p」という目印がヒモでつながっていたが、さらに「c2」ともヒモ付けるというのが正しいイメージになる。 少し奇妙な話だが「Parent p = new Child()」という文の左辺に登場する変数と、右辺で新規作成されているChildクラスのインスタンスは、全く別物だということになる。「p」は単なる目印に過ぎず、実体としてのオブジェクトは「new Child()」で生成されたまま、プログラム上からは見えないどこかしらに保管される。 次の行の「Child c1 = new Child()」も同じで、右辺ではもう一つ、Childクラスのインスタンスが新たに作成されているが、このオブジェクト本体はどこかしらに保管されていて、そのありかを「c1」という目印が間接的に示しているに過ぎない。 次の行「Child c2 = (Child)p」は、「c2」という目印が「p」という目印の指し示す実物を指し示すようにする働きを持つ。この行ではChildクラスのインスタンスの数は増えていない。ここまでで作成されたChildクラスのインスタンス(実物)は2つだけで、そのうち最初に作成された方には「p」と「c2」という二つの目印がヒモ付けられ、後に作成された方には「c1」という目印がヒモ付けられているという状態になる。 したがって次の行「p.val = 1」では、「p」にヒモ付けられているインスタンス(実物)の持つ「val」という変数の値を「1」にするので、「c2」から見ても「c2」にヒモ付けられている実物の持つ「val」が「1」に設定されたことになる。これは当然のことで、実物の方は1つしかなくて、それにヒモ付けられた目印が「p」「c2」の2つあるからだ。 これで「p==c1」が「false」になり、「p==c2」が「true」になる理由がはっきりした。「p==c1」は最初に作成された実物と、後に作成された実物が同一の物かどうかを調べているので、もちろん別物(false)である。「p==c2」は、後に作成された実物をそれ自身と同一の物かどうかを調べているので、もちろん同一のもの(true)である。 しかし現実の世界では、例えば2つの車がともに同じメーカー製の同じ型名で同じ色だというときには、「同じもの」だと見なしたいこともある。実物は別物でも、意味や機能の上では同じと見なしたい場合である。この「意味や機能の上では同じ」ということを表現するのが、Javaの場合は「equals」メソッドになる。 「equals」メソッドはJavaのあらゆるクラスの親であるObjectクラスで定義されていて、例題の「class Parent {}」のように「extends」を使わなかった場合、つまりどのクラスも明示的に拡張しなかった場合は自動的に「class Parent extends Object {}」と見なされ、Objectクラスを拡張したことになる。したがってParentクラスもChildクラスも自動的に「equals」メソッドをObjectクラスから受け継いでいる(継承している)。 例題のParentやChildのように自分で独自に定義したクラスで「equals」メソッドをオーバーライドしないままだと、基本的には「==」を使った比較と同じ結果を返す。例題のプログラムの中で作成される2つの実物のように、同じParentクラスおよびそれを継承するクラスのインスタンスで、なおかつ「val」が同じ値になっている場合に「意味や機能の上では同じ」と見なしたい場合は、「equals」メソッドを上書き定義(オーバーライド)する必要がある。
class Parent {
int val = 0;
public int calc(int i) {
return i+1;
}
public boolean equals(Parent p) {
return this.val == p.val;
}
}
例えばParentクラスの定義をこのように変更して、大元のObjectクラスにある「equals」メソッドの中身を上書き定義(オーバーライド)すると「p==c1」が「true」を返すようになり、「p」という目印が指し示す(参照する)実物と「c1」という目印が指し示す実物は別物だけれど、機能の上では「同じ」ということになる。「equals」メソッドをオーバーライドすることで、二つの異なるモノをどういう場合に「同じ」と見なすか、その意味付けをプログラマが自由に変更できる。 この点ついてはStringクラスを使った引っ掛け問題が出題されることもあるようだ。
String s1 = new String("ABC");
String s2 = new String("ABC");
System.out.println(s1 == s2);
このプログラムを実行したときの出力は「false」である。「String s1 = new String("ABC")」の行では「ABC」という値を持つStringクラスのインスタンス(実物)が作られて「s1」という目印とヒモ付けられる。次の行ではまた別の実物が作られて「s2」という目印とヒモ付けられる。この2つの実物が持っている値はたまたま「ABC」で同じだけれども、別物であることには違いない。したがって「s1 == s2」は「false」になる。 (訂正:読者の方からの指摘でJavaの言語仕様上、以下のようなコードの場合は「true」になることが分かった。筆者はこのコードの結果は「false」になると書いたが、ここにお詫びして訂正させて頂く。Javaは文字列リテラルについては、それがまったく同じ文字列である場合は同一のインスタンスを一か所にプールしておいて使い回すことになっているらしい。 String s1 = "ABC"; String s2 = "ABC"; System.out.println(s1 == s2); したがってこのコードに現われる二つの「"ABC"」は同一のStringクラスのインスタンスを参照し、結果は「true」になる。この仕組みを実装しているのはStringクラスのintern()というメソッドで、API文書によれば「intern メソッドが呼び出されたときに、equals(Object) メソッドによってこの String オブジェクトに等しいと判定される文字列がプールにすでにあった場合は、プール内の該当する文字列が返されます。そうでない場合は、この String オブジェクトがプールに追加され、この String オブジェクトへの参照が返されます」とある。) ところが「s1.equals(s2)」とすると結果は「true」になる。Stringクラスの「equals」メソッドが上書き定義されていて、中身の値が同じであるときは、実際には別物でも機能の上では同じと見なすことにしているわけだ。では次のプログラムの実行結果はどうなるか。 int i1 = 10; int i2 = 10; System.out.println(i1 == i2); このプログラムを実行したときの出力は「true」である。その理由は「int」がクラスではないから。クラスではない変数型のことをJavaでは「基本型」と呼んでいるらしい。Stringの場合と違って、このプログラムの中では「int」クラスのインスタンスが2つ作成された訳ではない。変数i1そのものが10になり、i2そのものが10になる。i1は実物とヒモ付けられた単なる目印ではなく、i1自身が10を値として持つ実物なのだ。 基本型の変数は単なる目印ではなくて値そのものだとすれば、比較するものは値そのものしかない。したがって「i1 == i2」は「true」にしかなりようがない。実物のように値以外に比べるものがあれば(たとえば車の製造番号など)、それを比べることで別物だと言うことができるけれども、基本型の変数については比べるものが値しかないので、値どうしの比較しかやりようがないし、「equals」メソッドなどというものを別に設ける必要もない。 初めてオブジェクト指向のプログラミング言語を学ぶ人にとっては、おそらくこの点を正しく理解できるかどうかが第一関門になるのだろう。基本型とクラスのインスタンス(オブジェクト)との違いが正確に理解できていれば次のような例題の問題点も理解しやすくなる。
public class RefArgTest {
static StringBuffer sb1 = new StringBuffer("A");
static StringBuffer sb2 = new StringBuffer("B");
static int i1;
public static void main(String[] args) {
RefArgTest rat = new RefArgTest();
rat.methodA(sb1, sb2, i1);
System.out.println(sb1+" "+sb2+" "+i1);
}
public void methodA(StringBuffer x, StringBuffer y, int a) {
y.append(x);
x=y;
a++;
x.append("XX");
}
}
このプログラムを実行したときの出力は「A BAXX 0」となる。ポイントはは「rat.methodA(sb1, sb2, i1)」の行で何が起こっているかだ。この行では、methodAというメソッドの1番目の引数としてsb1、2番目の引数としてsb2、3番目の引数としてi1を渡してメソッドを呼び出している。呼び出されたメソッドの中には、x、y、aというローカルな変数がある。 「x」には「static StringBuffer sb1 = new StringBuffer("A")」という行の右辺で作成され、「sb1」にヒモ付けられたStringBufferクラスの実物がヒモ付けられる。「y」には「static StringBuffer sb2 = new StringBuffer("B")」という行の右辺で作成され、「sb2」にヒモ付けられたStringBufferクラスの実物がヒモ付けられる。「a」は「i1」の持っている値(int型の初期値 0)そのものを自ら値として持つことになる。 methodAというメソッドの中で起こることを1行ずつ見ていく。「y.append(x)」という行では、「y」にヒモ付けられているStringBufferクラスの実物の末尾に、「x」にヒモ付けられているStringBufferクラスの中身をくっつけるので、「y」にヒモ付けられているStringBufferクラスの中身は「BA」に変化する。ところで「sb2」は「y」にヒモ付けられているのと同じ実物にヒモ付けられているので、「y」からたどって行ったStringBufferクラスの実物の中身もやはり「BA」になる。同一物なのだから当然といえば当然だ。 次の「x=y」という行では、今まで「static StringBuffer sb1 = new StringBuffer("A")」という行の右辺で作成された実物を指し示していた「x」が、今度は「y」にヒモ付けられている実物、つまり「static StringBuffer sb2 = new StringBuffer("B")」という行の右辺で作成された実物を指し示すようになる(先ほどの行でいまや値は「BA」に変わってしまっている)。ここで注意したいのは「sb1」がどの実物を指し示しているかということと、「x」がどの実物を指し示しているかということは、まったく関係ないということである。この「x=y」という行を通過してもなお、「sb1」は「static StringBuffer sb1 = new StringBuffer("A")」という行の右辺で作成された実物を指し示し続ける。つまり値が「A」のStringBufferの実物を指し示し続ける。「x」と「sb1」はこの行に来るまで、たまたま同じ実物にヒモ付けられていたに過ぎず、まったく別の目印なのだ。 次の行の「a++」では、「a」の持っている値「0」がインクリメントされて「1」になる。しかし「a」と「i1」はまったく別物で無関係なので、この段階では「a」の値が「1」、「i1」の値は依然として「0」のままである。 methodAというメソッドの最後の行「x.append("XX")」では、「x」が指し示しているStringBufferクラスの実物の末尾に「XX」という文字列を追加する処理だ。「x」という目印は、先ほどの「x=y」という行の結果として、いまや「static StringBuffer sb2 = new StringBuffer("B")」という行の右辺で作成された実物を指し示している。そしてこの右辺の実物は「y.append(x)」という行の結果としていまや「BA」という値を持っている。その末尾へ「XX」を付け加えるのだから、「static StringBuffer sb2 = new StringBuffer("B")」という行の右辺で作成された実物の中身は「BAXX」になる。これでmethodAというメソッドの処理は終わって、プログラムはもとに戻る。 さて、もとのmain()メソッドに戻ってきたとき、「sb1」は相変わらず「static StringBuffer sb1 = new StringBuffer("A")」という行の右辺で作成された実物を指し示し続けており、この実物の中身は変化していない。「sb2」は相変わらず「static StringBuffer sb2 = new StringBuffer("B")」という行の右辺で作成された実物を指し示し続けているが、この実物の中身は「BAXX」に変えられてしまっている。「i1」自身の持っている値は相変わらず「0」のままである。したがって「A BAXX 0」という出力になる。 この例題では「クラスのインスタンス」(オブジェクトとも呼ばれる)と基本型の振る舞いの違いがはっきりと現れている。メソッド(古風に「サブルーチン」と呼んでもいい)に引数を渡すとき、クラスのインスタンスの場合は実物への参照が渡される。したがってメソッドの中から大元の実物をたぐり寄せて、いろいろと中身をいじることができてしまう。それに対して基本型の方は値を渡すだけなので、呼び出し元の変数「i1」と呼び出し先の変数「a」はまったく別物になる。 ただしこのように正しく理解した上で、さらに次のような引っ掛け問題が出題されるおそれがあるのだから、サン認定Javaプログラマーの試験はあなどれない。
public class RefArgTest2 {
static String s1 = "A";
static String s2 = "B";
public static void main(String[] args) {
RefArgTest2 rat = new RefArgTest2();
rat.methodA(s1, s2);
System.out.println(s1+" "+s2);
}
public void methodA(String x, String y) {
y.concat(x);
x=y;
x.concat("XX");
}
}
このプログラムの実行結果は「A B」である。話が違うじゃないかと言いたくなるが、methodAの引数として渡されるのが実物への参照であることには違いない。methodAの内部で「x」は「static String s1 = "A"」の右辺で作成されたStringクラスの実物(値は「A」)にヒモ付けられ、「y」は「static String s2 = "B"」の右辺で作成されたStringクラスの実物(値は「B」)にヒモ付けられる。 ところが先ほどと違うのは「y.concat(x)」の結果である。StringBufferクラスの「append」メソッドとStringクラスの「concat」メソッドは、ある文字列の末尾に別の文字列を追加するという同じ機能を果たしているようで、やっていることは全く違う。この行は「y」が参照するStringクラスの実物の中身の末尾に、「x」が参照するStringクラスの実物の中身を付け加えた結果(つまり「BA」)を中身として持つような、新しいStringクラスの実物を作成する、という働きをする。で、この「BA」という中身を持つ新しいStringクラスの実物は、「static String s1 = "A"」の右辺で作成された実物とも別の物だし、「static String s2 = "B"」の右辺で作成された実物とも別の物で、このプログラムの中では3つめの、新たなStringクラスの実体なのである。 だが、せっかく新たに作成したこの3つめのStringクラスの実物は、「z=y.concat(x)」という具合に、新しい目印にヒモ付けられる訳ではないので、この行で作成されたまま、何と放置されてしまうのである。打ち捨てられた産業廃棄物といった感じである。もったいない。 このようにStringBufferクラスのappendメソッドと、Stringクラスのconcatクラスの振る舞いが全く異なるのは、ひとえにStringクラスの制約による。Stringクラスは「一度設定した中身は二度と変更できない」という制約をもったクラスなのである。concatやreplaceなど、一見、Stringクラスの個々のインスタンスの中身を変更するかに見えるメソッドはすべて、変更後の値を持つ新たな実体を作成する働きをする。 次の行の「x=y」の働きは、StringBufferを使った例題と働きは同じで、今まで「static String s1 = "A"」の右辺で作成されたStringクラスの実物にヒモ付けられていた「x」が、今度は「static String s2 = "B"」の右辺で作成されたStringクラスの実物にヒモ付けられるようになる。どちらの実物も依然として中身はそれぞれ「A」、「B」のままだ。 次の行の「x.concat("XX")」についても、xにヒモ付けられているStringクラスの実物(この段階では「static String s2 = "B"」の右辺で作成されたStringクラスの実物)には何の影響も与えず、その値の末尾に「XX」を追加した新たなStringクラスの実物を作成する。このプログラムでは4つ目のStringクラスの実物の登場で、やはり産業廃棄物として捨てられたままになる。したがってmethodAの処理が完了した段階では、「x」と「y」は同一のStringクラスの実物(この段階では「static String s2 = "B"」の右辺で作成されたStringクラスの実物)にヒモ付けられた状態で、呼び出し元の「s1」「s2」はそれぞれ「static String s1 = "A"」「static String s2 = "B"」の時点と同一のStringクラスの実物に相変わらずヒモ付けられている状態になっている。出力結果が「A B」になるのはそういう理由だ。 そういう訳で、ことStringクラスについては呼び出し元のStringクラスの中身を呼び出し先で変更できない。というよりも、一度作成したStringクラスの中身は、呼び出し元であろうが呼び出し先であろうが、いかなる場所であろうと変更できない。Stringクラスは使用頻度が高い割には、Javaのクラスの中では特殊な振る舞いをするので困ってしまう。 無断転載禁止
![]()
|