はじめに
自己紹介
皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、25年以上に渡って企業システムの開発に携わってきました。特にアーキテクトとして、ミッションクリティカルなシステムの技術設計や、Javaフレームワーク開発などの豊富な経験を有しています。
様々なセミナーでの登壇や雑誌への技術記事寄稿の実績があり、また以下のような書籍も執筆しています。
いずれもJava EE(Jakarta EE)を中心にした企業システム開発のための書籍です。中でも 「アプリケーションアーキテクチャ設計パターン」は、(Javaに限定されない)比較的普遍的なテーマを扱っており、内容的にはまだまだ陳腐化していないため、興味のある方は是非手に取っていただけると幸いです(中級者向け)。
Udemy講座のご紹介
この記事の内容は、私が講師を務めるUdemy講座『Java Basic編』の一部の範囲をカバーしたものです。『Java Basic編』はこちらのリンクから購入できます(セールス対象外のためいつも同じ価格)。また定価の約30%OFFで購入可能なクーポンをQiita内で定期的に発行していますので、興味のある方は、ぜひ私の他の記事をチェックしてみてください。
この講座は、以下のような皆様にお薦めします。
- Javaの言語仕様や文法を正しく理解すると同時に、現場での実践的なスキル習得を目指している方
- 新卒でIT企業に入社、またはIT部門に配属になった、新米システムエンジニアの方
- 長年IT部門で活躍されてきた中堅層の方で、学び直し(リスキル)に挑戦しようとしている方
- 今後、フリーランスエンジニアとしてのキャリアを検討している方
- 「Chat GPT」のエンジニアリングへの活用に興味のある方
- 「Oracle認定Javaプログラマ」の資格取得を目指している方
- IT企業やIT部門の教育研修部門において、新人研修やリスキルのためのオンライン教材をお探しの方
この記事を含むシリーズ全体像
この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。
7.3 オーバーロードと様々なメソッド呼び出し
チャプターの概要
このチャプターでは、メソッドとコンストラクタの仕様について、さらに深掘りして学びます。
7.3.1 メソッドとコンストラクタのオーバーロード
メソッドのシグネチャ
メソッドは戻り値、メソッド名、引数といった要素から構成されますが、この中で「メソッド名+引数の数と型」の組み合わせを「メソッドのシグネチャ」と呼びます。1つのクラスの中にシグネチャが同じメソッドを複数宣言すると、コンパイルエラーになります。
シグネチャの中に戻り値、引数の名前、修飾子は含まれない点に注意してください。例えば以下の4つのdoSomething()メソッドは同一のシグネチャと見なされるため、同じクラスの中に宣言するとコンパイルエラーになります。
(1) void doSomething(int x) { .... }
(2) String doSomething(int x) { .... } // (1)とは戻り値が異なる
(3) void doSomething(int y) { .... } // (1)とは引数名が異なる
(4) public void doSomething(int x) { .... } // (1)に修飾子を付与
メソッドのオーバーロード
メソッドは同一の名前であっても、シグネチャさえ異なれば1つのクラス内に複数宣言することができます。これをメソッドのオーバーロードと呼びます。例えば以下の3つのメソッドは同じ名前ですが、いずれも「引数の組み合わせ」が異なるため、別々のシグネチャと見なされます。そのため、これら3つのメソッドは、1つのクラス内に宣言することができます。
(1) int doSomething(int x) { .... }
(2) int doSomething(int x, int y) { .... }
(3) int doSomething(String str) { .... }
このようにオーバーロードされたメソッドが幾つかある場合、実際に呼び出されるメソッドは、どのように決まるのでしょうか。その答えは、渡された「引数の組み合わせ」によって、自動的に呼び出し対象メソッドが決まる、というものです。例えばdoSomething(30, 10)
と呼び出した場合は、その引数の組み合わせから、(2)が選ばれます。またdoSomething("foo")
呼び出した場合は、同じように引数の組み合わせから、(3)が選ばれます。
コンストラクタのオーバーロード
コンストラクタもメソッドと同じように、シグネチャさえ異なれば1つのクラス内に複数宣言することができます。これをコンストラクタのオーバーロードと呼びます。例えば以下の4つのコンストラクタは、異なるシグネチャと見なされるため、1つのクラス内に宣言することができます。
Person() { .... }
Person(String name) { .... }
Person(int age) { .... }
Person(String name, int age) { .... }
コンストラクタの設計と再利用
コンストラクタの設計は「クラスをどのように初期化させたいか」によって決まります。これは、データベースのテーブル設計におけるNOT NULL制約の考え方に似ています。フィールド数が多くなると、組み合わせの増大に伴って必然的にオーバーロードするコンストラクタの数も増大します。このとき、コンストラクタ内の処理で、同クラスのオーバーロードされたコンストラクタを再利用することができます。
具体的には以下の構文のようにします。
this(引数1, 引数2, 引数3, ....);
thisキーワードは、チャプター7.2で取り上げたとおり、クラス内において自身のインスタンスへの参照を表すための特殊な変数です。このようにthisキーワードに引数を渡すことで、自クラス内のオーバーロードされたコンストラクタを呼び出すことができます。なおthisキーワードによるコンストラクタ呼び出しは、コンストラクタ内の先頭に記述しないとコンパイルエラーになるため、注意が必要です。
それでは、コンストラクタを再利用する方法を具体的に見ていきましょう。Personクラスに、nameフィールド、ageフィールド、nationalityフィールドという3つのフィールドがあるものとします。このPersonクラスの初期化において、nameフィールドは必須、それ以外のフィールドは任意でどのように指定しても可、とするのであれば、以下の4つのコンストラクタをオーバーロードによって宣言することになるでしょう。
Person(String name) { .... }
Person(String name, int age) { .... }
Person(String name, String nationality) { .... }
Person(String name, int age, String nationality) { .... }
オーバーロードされたコンストラクタにおいて、thisキーワードによるコンストラクタ呼び出しを行うと、実装の再利用が可能になります。具体的には以下のコードを見てください。
Person(String name, int age) { //【1】
this.name = name;
this.age = age;
}
Person(String name, int age, String nationality) { //【2】
this(name, age);
this.nationality = nationality;
}
【1】は、nameフィールドとageフィールドを初期化するためのコンストラクタです。また【2】は、nameフィールドとageフィールド、そしてnationalityフィールドを初期化するためのコンストラクタです。
【2】の1行目を見ると、thisキーワードにnameとageの値を渡すことで、【1】のコンストラクタを呼び出しています。このようにすると、【2】において【1】の実装を再利用することが可能になります。
コンストラクタの課題
コンストラクタは前述したように、フィールド数が多くなると、数多くのコンストラクタをオーバーロードする必要性が生じます。thisキーワードによってコンストラクタの再利用はできますが、それを差し引いても、かなりの実装負担を強いられます。
またオーバーロードはあくまでも「引数の型と順番」のバリエーションのため、たとえばPersonクラスにおいて、新たにsiblingsNumberフィールド(兄弟の数)が追加になったとき、以下の2つの違いを吸収することはできません。
Person(String name, int age) { .... }
Person(String name, int siblingsNumber) { .... }
引数であるageもsiblingsNumberも、型は同じint型のため、両者は同じシグネチャと見なされるためです。このようなコンストラクタの課題を解消するために、昨今ではフィールド数が多い場合は、Builderパターン1というデザインパターンによってインスタンス生成するケースが増えています。
7.3.2 様々なメソッド呼び出し
メソッドによる同一クラス内での処理の共通化
前述したようにメソッドを定義する目的の1つは、共通的な処理を一か所にまとめることにあります。このような観点から、あるクラス内の複数メソッドにおいて共通的な処理があった場合に、それを別のメソッドに括り出すケースがあります。
ここで、既出のPersonクラスと同内容のPerson3クラスに「挨拶メッセージを生成して返すメソッド」を追加します。以下のコードを見てください。
String createGreetMessage(String language) {
String message = null;
if (language.equals("JAPANESE")) {
message = "おはよう!私は" + name + "、" + age + "歳です。";
} else {
message = "Good Morning! I'm " + name + "," + age + " years old.";
}
return message;
}
このメソッドでは、言語(引数language)が"JAPANESE"かそれ以外かを判定することによって、生成するメッセージが変わります。
今度はこのメソッドをオーバーロードし、以下のコードのようなメソッドを追加します。
String createGreetMessage(String language, boolean isMorning) {
String message = null;
if (isMorning) {
if (language.equals("JAPANESE")) {
message = "おはよう!私は" + name + "、" + age + "歳です。";
} else {
message = "Good Morning! I'm " + name + "," + age + " years old.";
}
} else {
if (language.equals("JAPAN")) {
message = "こんにちは!私は" + name + "、" + age + "歳です。";
} else {
message = "Good Afternoon! I'm " + name + "," + age + " years old.";
}
}
return message;
}
このメソッドでは、言語だけではなく、朝かどうか(引数isMorning)によってさらに生成するメッセージを分岐させます。ここでオーバーロードされた2つのcreateGreetMessage()メソッドを比較すると、if (language.equals("JAPANESE")) {}
のif文が同じ内容であることに気付きます。そこでコード(snippet_1)を再利用し、このメソッド(snippet_2)を以下(snippet_3)のように修正します。
String createGreetMessage(String language, boolean isMorning) {
if (isMorning) {
return createGreetMessage(language); //【1】
}
String message = null;
if (language.equals("JAPANESE")) {
message = "こんにちは!私は" + name + "、" + age + "歳です。";
} else {
message = "Good Afternoon! I'm " + name + "," + age + " years old.";
}
return message;
}
引数isMorningがtrueの場合、同一クラス内でオーバーロードされたメソッドを呼び出す【1】ことで、処理の共通化を図っています。もしisMorningがtrueでこの分岐に処理が進んだら、後はメソッドを抜けるだけなのでelseブロックは不要です。
なお【1】のように、return文に直接メソッド呼び出しを指定することができます。このように記述すると、メソッド呼び出しの結果をそのまま手を加えず戻り値として呼び出し先に返します。
このようなメソッド間の呼び出し関係を図に表すと、以下のようになります。
メソッド呼び出しのネスト
メソッドを呼び出し戻り値を受け取ったら、その値を変数に代入するのではなく、それをそのまま別のメソッドの引数に渡すことができます。このように、メソッド呼び出しはネストすることが可能です。
例えば、以下のような2つのメソッドがあるものとします。
String getUpperCase(String name) { //【1】
return name.toUpperCase();
}
String getGreeting(String name) { //【2】
return "Hello, " + name;
}
getUpperCase()メソッド【1】は、渡された文字列を大文字にして返すメソッドです。またgetGreeting()メソッド【2】は、渡された文字列に"Hello"を付け加えて返すメソッドです。それではこれらのメソッドを使って「"Alice"という文字列を大文字にし"Hello"を付け加えてその結果をコンソールに表示する」という処理を実現してみましょう。
そのためのコードは、以下のように一行で記述することができます。
System.out.println(getGreeting(getUpperCase("Alice")));
このコードを実行すると、まずgetUpperCase()メソッドに引数"Alice"が渡されます。そしてその戻り値である"ALICE"が、今度はgetGreeting()メソッドに引数として渡されます。そしてその戻り値である"Hello, ALICE"が、今度はSystem.out.println()メソッドに引数として渡されます。要はこの一行で3つのメソッドがネストして呼び出され、結果的にはコンソールに"Hello, ALICE"と表示されます。
メソッドの可変引数
メソッド宣言において引数を定義するとき、可変引数を利用することができます。可変引数とは、同じ型を持つ任意の数の引数を、配列として受け取るための機能です。
以下の構文を見てください。
戻り値型 メソッド名(型... 引数名) {
メソッド本体の処理
}
この構文における...
は、文字通りドットを3つ記述したものとなります。
例えば、以下のような可変引数を取るadd()メソッドを持つクラス(CalcForVariableParamsクラス)があるものとします。
class CalcForVariableParams {
int add(int... params) { //【1】
int sum = 0;
for (int i = 0; i < params.length; i++) {
sum += params[i];
}
return sum; // 合計を算出して返す
}
}
add()メソッド【1】は、引数paramsをint型の配列として受け取ることができます。
このメソッドは、以下のようにして呼び出します。
CalcForVariableParams calc = new CalcForVariableParams();
int answer = calc.add(15, 5, 30, 10); // いくつでも指定可能
メソッドの呼び出し元では配列を意識する必要はなく、int型の引数をいくつでも指定することができます。実はCalcForVariableParamsクラスのadd()メソッドは、引数に可変引数を使わず、int型配列で受け取るように宣言することもできますが、その場合は呼び出し元で配列を生成する必要があるため、可変引数を用いた方が簡潔です。
なお可変引数を宣言できるメソッドには、制約があります。この例のように可変引数のみを受け取るメソッドか、または、可変引数以外の引数がある場合は、最後に可変引数が来るメソッドに限定されます。例えばvoid doSomething(String str, int... params) { .... }
といったメソッドは宣言可能です。このメソッドは、someInstance.doSomething("Hello", 15, 5, 30, 10);
といった具合に呼び出します。
再帰呼び出し
再帰呼び出しとは、あるメソッドがそのメソッド内の処理において自メソッドを再帰的に呼び出すことです。再帰呼び出しによって、ループと同じように連続した処理を行うことが可能です。
ここで例として「引数として渡されたint型配列のすべての値を合計するメソッド」を取り上げます。これはfor文のループで実装すると、以下のようになります。
int calcSum(int[] array) {
int sum = 0;
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
return sum;
}
これと同じ処理を再帰呼び出しによって実装すると、以下のようなコードになります。
int calcSum(int[] array, int i) {
if (i < array.length) {
return array[i] + calcSum(array, ++i); //【1】
} else {
return 0;
}
}
このcalcSum()メソッドは、引数としてint型配列と処理する配列のインデックスの2つを受け取ります。メソッド内の処理を見ていくと、インデックスが配列のサイズの範囲内の場合に限り、「要素の値」と「再帰呼び出しの結果」を加算し、それを返却しています【1】。再帰呼び出しはcalcSum(array, ++i)
によって行っていますが、処理対象の配列と、インデックスを1つ増やした値を引数に渡しています。すると自身のメソッドが再帰的に呼び出され、1が加算されたインデックスに対して同じ処理が行われます。最終的にはインデックスの値が配列のサイズを超えた時点で0が返され、再帰の処理は終了します。
このメソッドを以下のようにして呼び出すと、1から10までの値を合計し、その結果を得ることができます。
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int sum = calcSum(array, 0);
System.out.println(sum); // 55
for文によるループと再帰呼び出しとは、両者とも実現できることはほぼ同じです。for文の方がシンプルでバグが混入する可能性が低いため、配列やコレクションといったデータを対象にループを行う場合は、for文を選択するべきです。
それでは再帰呼び出しはどのようなケースで利用するのでしょうか。それはツリー構造を持つデータを、一括で処理したいケースです。ツリー構造とは、親が複数の子を持ち、さらに子が親となって複数の子を持つようなデータ構造で、具体的にはファイルシステムのディレクトリ構造をイメージしてもらえれば分かりやすいでしょう。親から子へとツリー構造を「渡り歩く」ためには、子を対象にしたループ処理が必要ですが、子の中に深く入っていくためには、子を「次なる親」と見立てて自分を呼び返すような処理(=再帰呼び出し)が必要になるのです。なおこのように、ツリー構造を再帰呼び出しによって渡り歩くデザインパターンをCompositeパターン2と呼びます。
7.3.3 メソッドチェーン
メソッドチェーンとは
メソッドは、クラス型変数名.メソッド()
といった具合に.
によって呼び出します。さらに続けて、クラス型変数名.メソッドA().メソッドB().メソッドC()…
といった具合に、.
でメソッドをつなげていくことをメソッドチェーンと呼びます。
例えば既出のPerson3クラスのcreateGreetMessage()メソッドは、String createGreetMessage(String language) { .... }
と宣言されています。このメソッドを呼び出すとStringオブジェクトが返るので、メソッドをチェーンさせて、以下のようにStringクラスのstartsWith()メソッドを呼び出すことができます。
※startsWith()メソッドは、指定された文字列から始まることを判定するためのメソッド。
Person3 p = new Person3("Alice", 25);
if (p.createGreetMessage("JAPANESE").startsWith("おはよう")) { .... }
メソッドチェーンによるインスタンス構築
メソッドチェーンは、インスタンスを構築する際にも利用することができます。既出のPerson3クラスに、以下の2つのメソッドを追加してみましょう。
Person3 withAge(int age) {
this.age = age;
return this;
}
Person3 withNationality(String nationality) {
this.nationality = nationality;
return this;
}
これらのメソッドは、自クラスのフィールドを更新しreturn this
によって自身のインスタンスを返しています。従って生成したPersonインスタンスに対してメソッドをチェーンさせることで、フィールド値を連続して設定することができます。
Person3 p = new Person3("Alice");
p.withAge(25).withNationality("JAPAN");
まずPerson3クラスのインスタンスを生成します。次に、withAge()メソッドを呼び出すと、自身のインスタンス(Person型)が返されます。そしてそのインスタンスに対して、withNationality()メソッドをチェーンさせています。
上記コードは、さらに以下のように一文で記述することができます。
Person3 p = (new Person3("Alice")).withAge(25).withNationality("JAPAN");
このようにnew クラス名()
を( )
で囲うことによって、生成されたインスタンスに対してメソッドをチェーンさせることができます。
お馴染みのSystem.out.println()
は、コンソールに文字列を表示するためのAPIです。このAPIの仕組みは、チェーンの考え方によって理解することができます。まずSystem.out
は「Systemクラスのoutフィールド」です。そしてoutフィールドには、PrintWriterオブジェクトが格納されています。PrintWriterオブジェクトは、環境に応じて文字列を標準出力する機能を持っていますが、Eclipse上ではコンソールがその対象になります。従ってSystem.out.println()
を呼び出すと、コンソールに指定された文字列が出力される、という仕組みです。
7.3.4 クラス設計と状態
ステートレスなクラス
チャプター7.1からこのチャプターまで、クラスの基本的なメンバーである、フィールド、メソッド、コンストラクタについて説明してきました。クラスのメンバーであるフィールドは、クラスの属性や状態を表すものですが、クラスの責務次第によってフィールドの設計が変わってきます。
それではここで、改めて既出のCalculatorクラスを見てみましょう。これは「計算機」の機能を持ったクラスですが、フィールドは保持していません。
class Calculator {
int add(int x, int y) {
int result = x + y;
return result;
}
int subtract(int x, int y) {
int result = x - y;
return result;
}
}
Calculatorクラスは、以下のようにインスタンスを生成し、add()メソッドに引数を渡すことで足し算を行います。
Calculator calc = new Calculator();
int answer = calc.add(30, 10);
このようにクラスがフィールド(状態)を持たないことを、「ステートレス」と言います。
ステートフルなクラス
今度は同じ「計算機」ではありますが、「状態を持つ計算機」を作成してみましょう。以下のコードを見てください。
class StatefulCalc {
int x;
int y;
StatefulCalc(int x, int y) {
this.x = x;
this.y = y;
}
int add() {
int result = x + y;
return result;
}
int subtract() {
int result = x - y;
return result;
}
}
StatefulCalcクラスは、2つのフィールドxとyを持っていることに注目してください。このクラスは、以下のようにnew演算子に引数を渡してインスタンスを生成し、add()メソッドを呼び出す(引数なし)ことで足し算の結果を受け取ります。
StatefulCalc calc = new StatefulCalc(30, 10);
int answer = calc.add();
このようにクラスがフィールド(状態)を持つことを、「ステートフル」と言います。
状態を持つべきか持たざるべきか
既出のCalculatorクラスとStatefulCalcクラスは、いずれも同じことを実現できますが、その違いは両クラスの設計の意図にあります。
Calculatorクラスはステートレスのため、足し算や引き算といった業務処理に特化した、共通的な機能を提供するためのものと考えられます。一方でStatefulCalcクラスはステートフルのため、「計算機」というオブジェクトを表現するためのものです。StatefulCalcクラスでは、コンストラクタに渡した30と10という値が「計算機」の状態となり、その状態に対して、足し算や引き算という振る舞いがメソッドとして提供されています。例えば生成したインスタンスに対してadd()メソッドを呼び出した後も状態は引き続き保持されるため、続けてsubtract()メソッドを呼び出すと、今度は「30 - 10」の計算結果を受け取ることができます。また状態を保持したままStatefulCalcインスタンスを別クラスに引き渡し、そこで要件に合わせてadd()メソッドやsubtract()メソッドを呼び出してもらう、ということも可能です。
このようにクラスをどのような意図で設計するかによって、ステートレスかステートフルかは決まります。
ステートフルなクラスの方が、「状態と振る舞いが一体となっている」という点ではオブジェクト指向的と言えます。先に登場したPersonクラスは、様々な属性を持った「人物」というオブジェクトを表すクラスのため、ステートフルであることに意味があります。その一方でこの「計算機」の例のように、汎用的な業務処理がクラスの責務なのであれば、ステートレスの方が馴染むケースが多いでしょう。
このチャプターで学んだこと
このチャプターでは、以下のことを学びました。
- メソッドのシグネチャとオーバーロードについて。
- コンストラクタのオーバーロードについて。
- thisキーワードによるコンストラクタの再利用について。
- メソッドによって処理を共通化する方法について。
- メソッド呼び出しをネストさせる方法について。
- 可変引数による配列の受け取り方について。
- メソッドを再帰呼び出しする方法について。
- メソッドをチェーンさせる方法について。
- ステートレスなクラスとステートフルなクラスの違いや特徴について。