🍇

12.1 インタフェースと多態性~Java Basic編

2023/11/05に公開

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、25年以上に渡って企業システムの開発に携わってきました。特にアーキテクトとして、ミッションクリティカルなシステムの技術設計や、Javaフレームワーク開発などの豊富な経験を有しています。
様々なセミナーでの登壇や雑誌への技術記事寄稿の実績があり、また以下のような書籍も執筆しています。

いずれもJava EEJakarta EE)を中心にした企業システム開発のための書籍です。中でも 「アプリケーションアーキテクチャ設計パターン」は、(Javaに限定されない)比較的普遍的なテーマを扱っており、内容的にはまだまだ陳腐化していないため、興味のある方は是非手に取っていただけると幸いです(中級者向け)。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『Java Basic編』の一部の範囲をカバーしたものです。『Java Basic編』はこちらのリンクから購入できます(セールス対象外のためいつも同じ価格)。また定価の約30%OFFで購入可能なクーポンをZenn内で定期的に発行していますので、興味のある方は、ぜひ私の他の記事をチェックしてみてください。

この講座は、以下のような皆様にお薦めします。

  • Javaの言語仕様や文法を正しく理解すると同時に、現場での実践的なスキル習得を目指している方
  • 新卒でIT企業に入社、またはIT部門に配属になった、新米システムエンジニアの方
  • 長年IT部門で活躍されてきた中堅層の方で、学び直し(リスキル)に挑戦しようとしている方
  • 今後、フリーランスエンジニアとしてのキャリアを検討している方
  • Chat GPT」のエンジニアリングへの活用に興味のある方
  • Oracle認定Javaプログラマ」の資格取得を目指している方
  • IT企業やIT部門の教育研修部門において、新人研修やリスキルのためのオンライン教材をお探しの方

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

https://zenn.dev/kenya_saitoh/articles/3fe26f51ab001b

12.1 インタフェースと多態性~Java Basic編

チャプターの概要

このチャプターでは、実装と外部仕様を分離するための機能であるインタフェースと、インタフェースによる多態性について学びます。
なお多態性についてはすでにチャプター11.1で取り上げていますが、理解が十分でないと感じる場合は、必要に応じて復習をお願いします。

12.1.1 インタフェースの基本

インタフェースとは

インタフェースとは、オブジェクト指向言語において、実装と外部仕様とを分離するための仕組みです。実装と外部仕様を分離することにより、抽象化プログラミングを実現することが可能になります。
クラスが「モノ」を表す概念であるのに対して、インタフェースは外部から見た時の仕様を表現します。そしてその内容はクラスに実装されます。

インタフェースの具体的イメージ

このようなクラスとインタフェースのイメージを、ECサイトの「商品モデル」をもとに説明します。生活雑貨、紙の書籍、電子書籍といった商品を販売しているECサイトがあるものとして、それらの商品をモデルとして表すと、以下の図のようになります。

【図12-1-1】インタフェースとクラス

image.png

ぞれぞれの商品は「ネット購入可能」「ダウンロード可能」といった外部仕様を持つものとします。モノと外部仕様の対応関係としては、「生活雑貨」「紙の書籍」「電子書籍」は「ネット購入可能」、また「電子書籍」は「ダウンロード可能」という外部仕様を持つことになります。
オブジェクト指向開発では、商品つまりモノはクラスで表し、外部仕様はインタフェースで表します。このようにモノ(クラス)と外部仕様(インタフェース)を分離すると、どういったメリットがあるのでしょうか。
例えば商品をWebで購入するためのアプリケーションを開発する場合、様々な商品を対象にしたロジックを組む必要はなく、「ネット購入可能」というインタフェースのみを意識するだけで事足ります。また、ダウンロード機能を開発する場合であれば、「ダウンロード可能」というインタフェースのみを意識して設計すればよく、その実体が電子書籍であることを意識する必要はありません。これはまさに、チャプター11.1で取り上げた抽象化プログラミングの考え方そのものです。

インタフェースの特徴

Javaにおけるインタフェースには、以下のような特徴があります。

  • インタフェースは、クラスと同様にJavaにおけるプログラムの粒度の一種であり、コンパイルされるとクラスファイル(.classファイル)になります。従ってインタフェースは、「実装を持たないクラス」と考えることもできます。
  • インタフェースは外部仕様を表すために利用しますが、ここでいう外部仕様とは主に能力やスペックを意味します。従ってインタフェースには「動詞+able」といった名前を付けるケースがよく見られます。前頁の「商品モデル」の例であれば、「ネット購入可能」は「WebPurchasableインタフェース」、「ダウンロード可能」は「Downloadableインタフェース」といった命名になるでしょう。
  • インタフェースはクラスと同様に、変数の型として宣言することができます。必然的に、メソッドの引数や戻り値にも指定することが可能です。
  • インタフェースは、1つのクラスに複数実装することができます。クラスの継承では多重継承が認められていないため、この点は特徴的と言えます。
  • 抽象クラスと同じように、インタフェースによって多態性を実現できます。すなわち外形的には同じインタフェースの型であっても、インスタンス生成元クラスの実装に応じて振る舞いが変わります。

インタフェースの作成方法

それではインタフェースの作成方法を見ていきましょう。
インタフェースは以下のように宣言します。

【構文】インタフェースの宣言
interface インタフェース名 {
    ....抽象メソッド....
    ....デフォルトメソッド....
    ....スタティックメソッド....
    ....定数....
}

インタフェースは、interfaceキーワードと、その後ろにインタフェース名を記述することで宣言します。
1つのJavaランタイムの中で、その他のクラスやインタフェースと名前(FQCN)の競合は認められません。
interfaceキーワードとインタフェース名の間には、1つ以上のスペースが必要です。
インタフェース名の後ろには{ }を記述し、インタフェース全体の範囲を指定します。
インタフェースには、抽象メソッド、デフォルトメソッド、スタティックメソッド、定数といったメンバーを保持することができます。この中でも、特にインタフェースの中心的な役割を担うのは抽象メソッドです。抽象メソッドは、レッスン11.1.3でも説明したように、メソッド宣言はするもののそれ自体は中身を持たない空のメソッドです。

インタフェースの修飾子

インタフェースそのものに付与可能な修飾子はpublicのみです。
また、インタフェースの抽象メソッドに付与可能な修飾子には、以下の種類があります。

・public … すべてのクラスからアクセス可能であることを表します
・abstract … 抽象的な要素あることを表します
・strictfp … 浮動小数点の演算が実行環境に依存しないことを表します。ただし本コースでは対象外です。

実は、インタフェースにおける抽象メソッドの修飾子は、暗黙的にpublic abstractが付いているものと見なされます。抽象クラスにおける抽象メソッドにはabstractを付与する必要がありましたが、インタフェースにおけるメソッドには修飾子は付与しないのが一般的です。

インタフェースの実装

インタフェースは、単独では意味をなさず、必ず何らかのクラスに実装されなければなりません。
クラスにインタフェースを実装するためには、クラスを以下のように宣言します。

【構文】インタフェースの実装
class クラス名 implements インタフェース名1, インタフェース名2, .... {
    ........
}

このようにクラスにインタフェースを実装するためには、class宣言をするとき、implementsキーワードによってインタフェースを指定します。
インタフェースは前述したように、1つのクラスに複数implementsすることが可能です。
インタフェースをimplementsしたクラスでは、インタフェースに宣言された抽象メソッドを必ずオーバーライドして実装しなければなりません。この考え方は抽象クラスと同様です。
なおこのようにクラスにインタフェースを実装することを、本コースでは「implementsする」と呼称します。implementsは日本語では「実装する」ですが、一般的な意味での実装と区別するために、敢えてこのような呼称を使用します。

インタフェースとクラスの継承

インタフェースの実装は、親クラスの継承と同時に宣言することができます。その場合は、extendsを先に記述して以下のような宣言になります。

【構文】インタフェースの実装
class クラス名 extends 親クラス名 implements インタフェース名1, インタフェース名2, .... {
    ........
}

親クラスは1つしか継承できませんが、インタフェースは複数implementsできるため、必然的にこのような宣言になります。

インタフェースと型変換

レッスン11.1.4では、クラス型変数の型変換について取り上げましたが、インタフェース型も同様です。すなわちインスタンスを生成したら、そのクラスがimplementsするインタフェースに対してキャストすることが可能です。
例えばHogeクラスがFooインタフェースをimplementsしている場合、以下のコードが成り立ちます。

snippet
Foo foo = new Hoge();

またFoo型の引数を受け取るメソッドがあった場合、Hogeクラスのインスタンスを渡すことが可能です。

snippet
void doSomething(Foo foo) { // Foo型にキャストされて受け取る
    ........
}

このようなキャストによる代入は、Java SEが提供するAPIを使用する場合にも高い頻度で登場します。例えばコレクション(チャプター19.1参照)のコードでは、以下のように変数はListというインタフェース型で宣言し、実装クラスであるArrayListのインスタンスを生成して代入します。

snippet
List<String> list = new ArrayList<>();

また一度インタフェースにキャストされた後、実装クラスにダウンキャストすることも可能です。instanceof演算子によって、互換性を判定することができる点もクラス型と同様です。

インタフェースの仕組み

それではインタフェースの仕組みをコードを見ながら説明していきましょう。ここでインタフェースとしてFooとBar、それらをimplementsするクラスとしてHogeとPiyoがあるものとします。HogeクラスはFooのみをimplementsし、一方PiyoクラスはFooとBarの両者をimplementsします。
このような関係をクラス図に表すと以下のようになります。

【図12-1-2】クラス図(インタフェースのimplements)
image.png

クラス図では、インタフェースのimplementsは白抜き三角と点線で表します。またインタフェースであることを明示するために、インタフェース名の上部に「<<interface>>」と記述します。
それではまずFoo、Barのコードを順に示します。

pro.kensait.java.basic.lsn_12_1_1.Foo
interface Foo {
    void sayYes();
}
pro.kensait.java.basic.lsn_12_1_1.Bar
interface Bar {
    void sayHello();
    void sayGoodbye();
}

このようにインタフェースには抽象メソッドを宣言します。
次にHogeクラス、Piyoクラスのコードを、順に示します。

pro.kensait.java.basic.lsn_12_1_1.Hoge
public class Hoge implements Foo {
    @Override
    public void sayYes() { //【1】
        System.out.println("Hoge says YES!");
    }
}
pro.kensait.java.basic.lsn_12_1_1.Piyo
public class Piyo implements Foo, Bar {
    @Override
    public void sayYes() { //【2】
        System.out.println("Piyo says YES!");
    }
    @Override
    public void sayHello() { //【3】
        System.out.println("Piyo says Hello!");
    }
    @Override
    public void sayGoodbye() { //【4】
        System.out.println("Piyo says Goodbye!");
    }
    public void sayGoodnight() { //【5】
        System.out.println("Piyo says Goodnight!");
    }
}

HogeクラスはFooインタフェースをimplementsしているため、sayYes()メソッド【1】の実装が必要です。一方でPiyoクラスはFoo、Barの両者をimplementsしているため、sayYes()【2】、sayHello()【3】、sayGoodbye()メソッド【4】の3つを実装しなければなりません。またPiyoクラスのsayGoodnight()メソッド【5】のように、インタフェースで宣言されていないメソッドを追加することも可能です。
このようにPiyoクラスのメソッドを見ると、Fooで宣言されたメソッド、Barで宣言されたメソッド、独自のメソッドに分類できます。つまりクラスにとってインタフェースとは「メソッドのみを部分集合的にグループ化したもの」と捉えることができます。

それでは作成したインタフェースとクラスを呼び出して、多態性の挙動を確認してみましょう。以下のコードを見てください。

snippet (pro.kensait.java.basic.lsn_12_1_1.Main)
Foo foo1 = new Hoge(); //【1】
Foo foo2 = new Piyo(); //【2】
foo1.sayYes(); //【3】
foo2.sayYes(); //【4】

Hogeクラスでインスタンスを生成したら、Fooインタフェース型の変数foo1に代入します【1】。またPiyoクラスでインスタンスを生成したら、同じようにFooインタフェース型の変数foo2に代入します【2】。そしてfoo1のsayYes()メソッドを呼び出す【3】と「Hoge says YES!」、foo2のsayYes()メソッドを呼び出す【4】と「Piyo says YES!」と表示されます。
このように外形的には同じFoo型であるにもかかわらず、インスタンス生成元クラスによって振る舞いが変わります。

今度は、メソッド呼び出しの引数にインタフェース型を指定した場合の挙動について見ていきます。以下のような2つのメソッドがあった場合、

snippet
void talk(Foo foo) { //【1】
    foo.sayYes();
}
void greet(Bar bar) { //【2】
    bar.sayHello();
    bar.sayGoodbye();
}

PiyoクラスはFoo、Barの両者をimplementsしているため、Piyoインスタンスはtalk()メソッド【1】、greet()メソッド【2】のどちらにも渡すことができます。
greet()メソッド【2】に注目すると、Piyoインスタンスはもちろん受け取れますが、それ以外のクラスであってもBarをimplementsしたクラスのインタフェースであれば受け取ることが可能です。言い方を替えると、greet()メソッドはBarの外部仕様にしか関心がない(どのように実装されていようと関係がない)、というわけです。

12.1.2 インタフェースのメリットと様々な機能

インタフェースの具体例

それでは先に取り上げたECサイトの「顧客モデル」をもとに、インタフェースの挙動を説明します。
ここでは、顧客には、一般会員、ゴールド会員、プラティナ会員という3つの種別があるものとします。これをクラス図に表すと以下のようになります。

【図12-1-3】クラス図(顧客モデル)
image.png

この中で、まずCustomerBaseクラスは抽象クラスで、その他のクラスの親になります。一般会員、ゴールド会員、プラティナ会員は、それぞれGeneralCustomerクラス、GoldCustomerクラス、PlatinumCustomerクラスとなり、CustomerBaseクラスを継承します。コードはレッスン11.1.3と殆ど同じですが、CustomerBaseクラスのフィールドを以下のように種類を増やします。またそれに合わせてアクセサメソッドも追加するものとします。

pro.kensait.java.basic.lsn_12_1_2.CustomerBase
public abstract class CustomerBase {
    protected int id; // ID
    protected String name; // 名前
    protected String address; // 住所
    protected int point; // ポイント
    protected boolean invalid; // 無効フラグ
    ........
}

ここで、この「顧客モデル」に新たな仕様を追加してみましょう。具体的には、一般会員とゴールド会員について、同じ名前と住所を持つ2人の顧客を同一のデータとして統合する機能(いわゆる名寄せ)を追加します。
この機能はプラティナ会員は対象外なので、CustomerBaseクラスの中で共通実装として提供することはできません。そこでこの機能を実現するために、新たにNameAggregatorインタフェースを定義します。
このようなクラス・インタフェース間の関係性をクラス図に表すと、以下のようになります。

【図12-1-4】クラス図(顧客モデル)
image.png

さて名寄せをするために必要な外部仕様には、いったいどういったものが考えられるでしょうか。まず同一人物かどうかを検証するために、名前と住所を取得すること。名寄せ後にポイントを統合するために、ポイントを取得したり更新したりすること。さらに名寄せによって統合された顧客は無効にする必要があるため、状態を無効にすること。こういった外部仕様が考えられます。
これらを踏まえて、NameAggregatorインタフェースを以下のコードのように作成します。

pro.kensait.java.basic.lsn_12_1_2.NameAggregator
interface NameAggregator {
    String getName(); // 名前を取得する
    String getAddress() ; // 住所を取得する
    int getPoint(); // ポイントを取得する
    void setPoint(int point); // ポイントを更新
    void invalidate(); //状態を無効にする
}

そしてこのNameAggregatorインタフェースを、GeneralCustomerクラスとGoldCustomerクラスでimplementsします。このインタフェースで宣言された大半のメソッドは、CustomerBaseクラスで提供されていますので、追加になるのはinvalidate()メソッドのみです。

snippet
// 状態を無効にする(オーバーライド)
@Override
public void invalidate() {
    setInvalid(true);
}

これでGeneralCustomerクラスとGoldCustomerクラスには、名寄せを行うための外部仕様が充足されたことになります。

次に名寄せの処理ですが、ここではユーティリティクラスに任せることにします。具体的には、名寄せを行うためのユーティリティとして新たにNameAggregatorsクラスを作成します。

pro.kensait.java.basic.lsn_12_1_2.NameAggregators
public class NameAggregators {
    // 【1】名寄せ前のチェックメソッド
    public static boolean isSame(NameAggregator n1, NameAggregator n2) {
        if (n1.getName().equals(n2.getName())
                && n1.getAddress().equals(n2.getAddress())) {
            return true;
        }
        return false;
    }
    // 【2】二人の顧客を統合するメソッド
    public static NameAggregator aggregate(NameAggregator n1, NameAggregator n2) {
        n1.setPoint(n1.getPoint() + n2.getPoint());
        n2.invalidate();
        return n1;
    }
}

このクラスには、名寄せ前に二人の顧客が同一かどうかをチェックするためのメソッド【1】と、二人の顧客を統合するためのメソッド【2】が実装されています。いずれのメソッドも、引数の型がCustomerBaseベース型ではなく、NameAggregatorインタフェース型である点に注目してください。これらのメソッドに渡すことができるのは、NameAggregatorインタフェースをimplementsしたGeneralCustomerクラスとGoldCustomerクラスのインスタンスだけになります。
このようにインタフェースを利用すると、クラスの階層構造とは無関係に機能追加を行い、多態性によって処理を共通化することが可能になります。

インタフェースと定数

インタフェースには、メンバーとしてフィールドを保持することができますが、無条件にpublic static finalという修飾子が付いているものと見なされます。必然的に、フィールドは定数として使用することになります。これらの修飾子の付与は任意ですが、定数であることを明示するために敢えて付与するケースの方が多く見かけます。
インタフェースにおける定数は、主に当該インタフェースに関連する複数の定数をまとめて定義するために用います。

インタフェースとデフォルトメソッド

これまで説明してきたように、インタフェースの主役はあくまでも抽象メソッドです。インタフェースには、デフォルトメソッドと呼ばれる機能があり、1つだけ実装を持つメソッドを定義することができます。この機能を利用すると、クラスの多重継承ができないという制約を、ある程度解消することができます。
インタフェースのデフォルトメソッドは、メソッドの先頭にdefaultキーワードを付与して宣言します。
それでは、前項まで例として用いてきたECサイトの「顧客モデル」をもとに、デフォルトメソッドの挙動を説明します。ここで、再びこの「顧客モデル」に新たな仕様を追加してみましょう。具体的には、ゴールド会員とプラティナ会員に限って、家族会員としてグループ化できるという機能を追加します。この機能は、一般会員は対象外なので、CustomerBaseクラスの中で共通実装として提供することはできません。そこでこの機能を実現するために、新たにFamilySpecインタフェースを定義します。
このようなクラス・インタフェース間の関係性をクラス図に表すと、以下のようになります。

【図12-1-5】クラス図(顧客モデル)
image.png

FamilySpecインタフェースのコードを、次に示します。

pro.kensait.java.basic.lsn_12_1_2.FamilySpec
interface FamilySpec {
    //【1】家族会員を取得する
    CustomerBase[] getFamily();
    //【2】家族会員を設定する
    void setFamily(CustomerBase[] family);
    //【3】家族会員かどうかを判定する
    default boolean isFamily(CustomerBase target) {
        for (CustomerBase customer : getFamily()) {
            if (target.equals(customer)) return true;
        }
        return false;
    }
}

このインタフェースには、2つの抽象メソッドと1つのデフォルトメソッドがあります。まず抽象メソッドであるgetFamily()メソッド【1】とsetFamily()メソッド【2】は、自身にとっての家族会員を、配列として取得したり設定したりするためのものです。これらのメソッドは、このインタフェースをimplementsするクラスにおいて実装が必要です。
次にデフォルトメソッドであるisFamily()メソッド【3】は、引数に渡された顧客が自身の家族かどうかを判定するためのメソッドです。このメソッドは、引数とgetFamily()メソッド呼び出しだけで完結するため、このようにインタフェースの中で実装までを行っています。処理内容としては、引数として渡された顧客が、getFamily()によって返される家族メンバーのいずれかと一致しているかを、ループによってチェックします。
そして一致していた場合は「家族である」と見なしてtrueを返す、という仕様です。

このようにして作成したFamilySpecインタフェースは、GoldCustomerクラスとPlatinumCustomerクラスでimplementsします。例えばGoldCustomerクラスでは、CustomerBase[]型のfamilyフィールドを宣言した上で、getFamily()メソッドとsetFamily()メソッドを、以下のようにオーバーライドします。

snippet (pro.kensait.java.basic.lsn_12_1_2.GoldCustomer)
@Override
public CustomerBase[] getFamily() {
    return family;
}
@Override
public void setFamily(CustomerBase[] family) {
    this.family = family;
}

このようにデフォルトメソッドを利用すると、実装を伴った共通的な機能を、クラスの階層構造とは無関係に追加することが可能になります。

インタフェースとスタティックメソッド

インタフェースには、メンバーとしてスタティックメソッドを持つことができます。インタフェースのスタティックメソッドは、主に当該インタフェースを対象としたユーティリティとして利用します。例えば前項まで例として用いてきたECサイトの「顧客モデル」では、NameAggregatorインタフェースを処理するためのユーティリティとして、NameAggregatorsクラスを作成しました。この例では理解しやすさの観点から敢えて別クラスにしたのですが、実はNameAggregatorインタフェースの中に、スタティックメソッドとしてNameAggregatorsクラスのユーティリティを定義することができます。
ユーティリティが必要な場合は、インタフェースに直接実装してしまった方がコードとしては簡潔になるでしょう[1]

インタフェース同士の継承

インタフェースは、親となる別のインタフェースを継承することができます。その場合、extendsキーワードによって、インタフェースを以下のように宣言します。

【構文】インタフェース同士の継承
interface インタフェース名 extends 親インタフェース名1, 親インタフェース名2, .... {
    ........
}

クラスの継承とは異なり、インタフェースは多重継承が可能です。子となるインタフェースには、クラスと同じような考え方で、親インタフェースのメンバーが引き継がれます。機能的に結び付きが強い複数のインタフェースがあった場合は、継承によって1つに統合すると再利用性の向上が期待できます。

メソッドの衝突

インタフェースは1つのクラスに複数implementsすることができるため、抽象メソッドの衝突が発生する可能性があります。仮に複数のメソッド間で「戻り値+シグネチャ」が完全に同じ場合は、それらを1つのメソッドで同時にオーバーライドすることになるため、コンパイルエラーは発生しません。
ただし複数のメソッド間でシグネチャのみは同じで、戻り値型が異なる場合は、衝突を解決する手段は基本的にありません。それらのメソッドは必ずオーバーライドしなければなりませんが、オーバーライドすると「シグネチャが同じメソッドは複数宣言できない」というルールに抵触し、コンパイルエラーになってしまうためです。

【図12-1-6】メソッドの衝突
image.png

このチャプターで学んだこと

このチャプターでは、以下のことを学びました。

  1. インタフェースとは実装と外部仕様とを分離するための仕組みであること。
  2. インタフェースは能力やスペックを表すこと。
  3. インタフェースを宣言する方法について。
  4. インタフェースの修飾子について。
  5. クラスにインタフェースを実装する方法について。
  6. インタフェースによりクラスの階層構造とは無関係に機能追加を行い、多態性によって処理を共通化することが可能になること。
  7. インタフェースにおけるデフォルトメソッドについて。
  8. インタフェースにおけるスタティックメソッドについて。
脚注
  1. Java 8以前はインタフェースの中にスタティックメソッドを持てなかったため、当時のJava SEのAPIにはインタフェースとユーティリティクラスが対になって別々に提供されるケースがあった。そのときのユーティリティクラスは、この例のようにインタフェース名の最後に"s"を付けるという命名パターンが多く、例えばjava.util.Collectionインタフェースとjava.util.Collectionsクラスという組み合わせがその代表。ただしJava 8でインタフェースの中にスタティックメソッドを定義できるようになってからは、このような組み合わせは少なくなった。例えばJava 9では、java.util.Listインタフェースの中にスタティックなof()メソッドが提供されるようになった。 ↩︎

Discussion