はじめに
今回はオブジェクト指向について書いていきます。
オブジェクト指向について「知ってるような、知らないような」そんな感じの方が多いのではないでしょうか。
エンジニア歴3年目に入った私もまさにその程度で、「言葉はもちろん知ってるんだけど、説明しろと言われるとなぁ」みたいな感じでした。
今回はそんなふんわりとした理解を改めるために記事を書きましたので、皆様の参考になると幸いです。
オブジェクト指向とは
それではオブジェクト指向について解説していきます。
まず、「オブジェクト指向ってなに?」と聞かれると、「ん~、なにって言われるとなぁ」と、困ってしまいますよね。
Wikipediaによれば「オブジェクト指向プログラミングとは、「オブジェクト」という概念に基づいたプログラミングパラダイムの一つである。」と記述されています。
Wikipediaでさえ、良く分からない回答をしているので、我々がパッと回答を出せるわけありません。
次にGeminiに聞いてみました。
Gemini「プログラムを『手順の羅列』から、『便利な部品の組み合わせ』に変える考え方」
さすがGemini!めっちゃ分かりやすい!
つまり、オブジェクト指向とは「部品を組み立てるようにプログラミングすること」と言えますね。
なぜ必要なのか
なんとなく、概要がつかめたところで「じゃあ、なんで便利なの?」が、気になります。
今やオブジェクト指向型ではないプログラミング言語を見る方が珍しくなりましたので、使われているのはそれなりに理由があるはずです。
これはプログラミング言語の歴史を辿ると分かりやすくなります。
初期の言語: すべてのコードに番号(行番号)が振ってありました。
指示の方法: 「50行目に飛んで!その次は85行目!」というようにイチイチ指示を出さなくてはいけませんでした。
問題発生: インターネットの時代に突入するとソースコードが巨大化。関数が膨大な量になり、管理不能な状態に。
そこで生まれたのが「オブジェクト指向型」というわけです。
オブジェクト指向はこれまでのプログラミング言語の大きな2つの課題を解決しました。
1点目はバグの減少です。
2点目は再利用性の向上です。
これらがどのような機能によって解決されたのか見ていきましょう。
「クラス」と「インスタンス」
「クラス」と「インスタンス」は、オブジェクト指向の根幹ともいえる機能ですので、解説していきます。
一般的にクラスは設計図、インスタンスは実態と言われます。
ただ、オブジェクト指向の理解が浅い中で「設計図」や「実態」と言われても、「ナニソレ・・・」状態だと思います。
今回はおにぎりを例に出して説明していきます。
いきなりですが、おにぎりの型って使ったことありますか?
こういうやつです。

おにぎりを頻繁に作る私ですが、正直言っておにぎりは型なしでは綺麗な三角形に作れません。
しかし、この型を一度購入すると、綺麗な三角形をしたおにぎりが100個でも200個でも作れるようになるわけです。
型があると再現性が生まれるというわけですね。
ここまで説明した中で「型」に当てはまるのがいわゆる「クラス」です。
そして型に米と具を注入する流れのことを「インスタンス化」と言います。
そして出来上がったおにぎりが「インスタンス」ということですね。
こんなイメージです
実際にコードにしてみるとこんな感じになります。
// 1. 【クラス】これが「おにぎりの型」です
class Onigiri {
// おにぎりのデータ(中身)
String riceType; // 米の種類
String filling; // 具の種類
// コンストラクタ:型にご飯と具を詰める作業
public Onigiri(String riceType, String filling) {
this.riceType = riceType;
this.filling = filling;
}
// おにぎりの機能(食べる)
public void eat() {
System.out.println(this.riceType + "で作った" + this.filling + "おにぎりを食べます。モグモグ...");
}
}
public class Main {
public static void main(String[] args) {
// 2. 【インスタンス化】型(Onigiri)に米と具(データ)を入れて作る!
// 「new Onigiri」が、型を使って作っている瞬間です
Onigiri sakeOnigiri = new Onigiri("白米", "鮭");
Onigiri umeOnigiri = new Onigiri("玄米", "梅干し");
// 3. 【インスタンス】出来上がった「実体」
// sakeOnigiri や umeOnigiri が、実際に食べられるおにぎり(インスタンス)です
// 実際に食べてみる(メソッドの実行)
sakeOnigiri.eat(); // 出力: 白米で作った鮭おにぎりを食べます。モグモグ...
umeOnigiri.eat(); // 出力: 玄米で作った梅干しおにぎりを食べます。モグモグ...
}
}
型(クラス)は1つですが、中に入れる具(データ)を変えることで、無限に違うおにぎり(インスタンス)を生み出せるのがわかります。
オブジェクト指向の3大要素
ここからはオブジェクト指向が持つ機能をもう少し深堀していきます。
カプセル化について
カプセル化とは中身を隠して、外からは決められた操作しかさせないようにすることです。
カプセル化のメリットは、意図しないデータ操作を防ぐことです。 もしカプセル化をしなかった時のことを考えてみると、カプセル化のありがたみがよく理解できます。
カプセル化されていない(悪い)例
これがカプセル化をしていない例です。
class BadBankAccount {
// publicなので、誰でも外から直接書き換え可能
public int balance; // 残高
}
public class Main {
public static void main(String[] args) {
BadBankAccount myAccount = new BadBankAccount();
// いきなり「-100万円」を設定できてしまう(現実ではありえない)
myAccount.balance = -1000000;
System.out.println("残高: " + myAccount.balance);
}
}
このように、カプセル化が行われないと、どの関数からでも好きなように変数の値を書き換えることができてしまいます。
カプセル化された(良い)例
では、カプセル化が存在するコードを見てみましょう。
// 良い設計のクラス(カプセル化)
class GoodBankAccount {
// 1. データは「private」で隠す(外部から直接触れない)
private int balance;
// 2. アクセスするための「窓口(メソッド)」を用意する
// お金を預けるメソッド(不正な値をチェックできる!)
public void deposit(int amount) {
// もし預入額が0以下なら、処理を拒否する
if (amount <= 0) {
System.out.println("エラー:預け入れ額は1円以上である必要があります。");
return; // 処理を中断
}
// チェックを通過した場合のみ、データを変更する
this.balance += amount;
System.out.println(amount + "円 預けました。");
}
// 残高を確認するだけのメソッド(見るだけOK、書き換え不可)
public int getBalance() {
return this.balance;
}
}
public class Main {
public static void main(String[] args) {
GoodBankAccount myAccount = new GoodBankAccount();
// myAccount.balance = -1000000;
// ↑ エラー! privateなので直接さわれない(コンパイルエラーになる)
// 正しい窓口(メソッド)経由で依頼する
myAccount.deposit(-1000000); // if文が弾く
myAccount.deposit(5000); // これはOK
// 残高を見る
System.out.println("現在の残高: " + myAccount.getBalance());
}
}
このようにカプセル化をすることによって、「どの値をどこから変更することができるのか」を定義することができます。
カプセル化によって、予期しないエラーを防ぐことができるのです。
継承について
次に、継承について解説していきます。
継承とは、すでに存在するクラスを用いて、新しい機能を追加することです。
これが存在しないと、すでに存在するクラスと似たようなクラスを作る場合でも、新しく自分でゼロから作らなきゃいけなくなります。
めちゃくちゃ面倒くさいですよね。
継承することによって、今まで存在するクラスに「1個だけ機能を追加する」といったことができるようになります。 具体的な事例を見ていきましょう。
継承を使用しない(悪い)例
以下の内容が、継承を使用していない状態です。同じようなコードを何度も書く羽目になります。
// 一般社員クラス
class Employee {
String name;
void introduce() {
System.out.println("私の名前は" + name + "です。");
}
void work() {
System.out.println("事務作業をします。");
}
}
// エンジニアクラス
// ★問題点:Employeeとほぼ同じコード(name, introduce)をまた書いている!
class Engineer {
String name; // 重複
void introduce() { // 重複
System.out.println("私の名前は" + name + "です。");
}
// ここだけがエンジニア独自
void work() {
System.out.println("プログラミングをします。");
}
}
以下は継承を用いてなるべく重複を減らした良い例です。
// 1. 親クラス(共通の命令を決める)
class Employee {
void work() {
System.out.println("何かの仕事をします");
}
}
// 2. 子クラス(具体的な中身を上書き=オーバーライドする)
class Engineer extends Employee {
@Override
void work() {
System.out.println("プログラムを書きます"); // エンジニア独自の動き
}
}
class Sales extends Employee {
@Override
void work() {
System.out.println("営業に出かけます"); // 営業独自の動き
}
}
public class Main {
public static void main(String[] args) {
// 「Engineer」や「Sales」を、親の「Employee」という型でまとめて扱える
Employee[] employees = new Employee[2];
employees[0] = new Engineer(); // 実体はエンジニア
employees[1] = new Sales(); // 実体は営業
for (Employee e : employees) {
// eの中身がエンジニアなら「プログラム」、営業なら「営業」が勝手に実行される
e.work();
}
}
}
このように重複した記述を減らせるのが継承のポイントとなります。
ポリモーフィズム
オブジェクト指向の機能の3つ目のポリモーフィズムについて解説します。
ポリモーフィズムは日本語で言うと多態性という意味で、同じ名前の関数を複数のクラスで使用することができるようになります。
例えば、電子レンジクラスを作るとして、製品Aの電子レンジと製品Bの電子レンジで共通の機能は必ずありますよね。
温める機能とか、自動洗浄機能とか。
共通の機能は同じ関数名にしておきたいです。
microwaveCleanAやmicrowaveCleanBなんて関数を作ったら製品ごとに関数が増えていってしまって、「どの関数名が○○の商品だっけ?」と混乱してしまいます。
microwaveClean関数を1つだけ作成し、中身だけ製品ごとに変わっている方が分かりやすいですよね。
それをできるのがポリモーフィズムの良い点です。
どのように実装にするのか見ていきましょう。
先ほどExtendsの事例は見たので今回はinterfaceを使用していきます。
// 電子レンジの規格(Interface)
interface Microwave {
// 1. 温める機能(中身なし)
void warmUp();
// 2. 掃除する機能(中身なし)
void microwaveClean();
}
続いて、定義した内容を用いて機能を作成します。
// パターンA:高級なスチームオーブンレンジ
class HighEndRange implements Microwave {
@Override
public void warmUp() {
System.out.println("過熱水蒸気で、しっとりと温めます。");
}
@Override
public void microwaveClean() {
System.out.println("自動洗浄モードで、内部をスチーム洗浄します。");
}
}
// パターンB:一人暮らし用の安いレンジ
class CheapRange implements Microwave {
@Override
public void warmUp() {
System.out.println("ウィーン!マイクロ波でガンガン温めます。");
}
@Override
public void microwaveClean() {
System.out.println("機能はありません。雑巾で拭いてください。");
}
}
上記のように記述しインスタンス化すると、高級レンジの温め機能も安いレンジも同じwarmUpという関数で呼び出すことができます。
以上のように、オブジェクト指向は開発者の脳みそをスッキリさせてくれる機能が様々存在します。
全てマスターすると大規模なシステムがきれいに作れるようになるかもしれません。
オブジェクト指向を用いた設計
ここからはオブジェクト指向を用いた設計について解説していきます。
既存のアプリケーションに新しいクラスを作る際に「過去の実装方針を踏襲したいが、何を方針にしているのかが分からない」という経験をしたことはありませんか。
過去の開発者が何を目指してアプリケーションを設計していたのかは、オブジェクト指向のよくある2つの考え方を理解すると分かりやすいですのでご紹介していきます。
結合度と凝集度
1つ目は結合度と凝集度です。
これはオブジェクト指向が目指すゴールのようなものです。
具体的な定義は以下の通りです。
結合度:1つのクラスを変更した時にどれだけ他のクラスに影響を与えるか
凝集度:1つのクラスの中にどれだけ機能が詰まっているか
なるべく結合度は低い方が良くて凝集度はなるべく高い方がいいです
具体的な例を見ていきましょう
// 低凝集(悪い例)
public class StickyOrderManager {
// ❌ 具体的なクラスを直接 new している(密結合)
// この "MySQLDatabase" が変わると、このクラスも壊れる
private MySQLDatabase db = new MySQLDatabase();
private GmailMailSender mailer = new GmailMailSender();
public void processOrder(String item, int price) {
// --- 具材1:計算 ---
// ここに消費税計算ロジックが直書きされている
int total = (int)(price * 1.1);
// --- 具材2:保存 ---
// SQLがここに直書きされている
db.connect();
db.execute("INSERT INTO orders VALUES ('" + item + "', " + total + ")");
// --- 具材3:通知 ---
// メールの文面作成がここに直書きされている
mailer.send("注文を受け付けました: " + item);
}
}
このケースは密結合で低凝集です。
なぜ密結合と判断できるかというと仮に MySQLDatabaseクラスがOracleDatabaseへ書き換わると、StickyOrderManagerクラスも動かなくなってしまうからです。
様々なクラスで同じようなことが起きるとその回数分、修正を加えなきゃいけなくなります。
凝集度が低いと判断できる理由はこの1つのクラスに「計算ロジック」「保存ロジック」「通知ロジック」が全て入っているからです
仮に「消費税計算ロジック」に修正を加えて間違えた修正をした場合、なぜかSQLの保存がうまくいかなくなる可能性があることが低凝集の問題です。
それでは良い事例を見ていきましょう。
まずはインターフェースを設置します。
// 保存担当のフィルム(どんなDBかは問わない)
interface OrderRepository {
void save(Order order);
}
// 通知担当のフィルム(メールかSlackかは問わない)
interface Notifier {
void send(String message);
}
そのインターフェースを使用した形で関数を作成します
// 計算担当のおにぎり
class PriceCalculator {
public int calculateTax(int price) {
return (int)(price * 1.1);
}
}
// 実際の保存担当(MySQL版)
class MySQLRepository implements OrderRepository {
public void save(Order order) { /* SQL処理... */ }
}
それらを用いて メイン クラスを作ります
// 【良いクラス】流れの制御だけに集中(高凝集)
public class CleanOrderService {
private final OrderRepository repository; // インターフェース(フィルム)
private final Notifier notifier; // インターフェース(フィルム)
private final PriceCalculator calculator; // 計算専門家
// 外部から「具」を渡してもらう(DI: Dependency Injection)
// どんなDBや通知手段が来てもOK
public CleanOrderService(OrderRepository repo, Notifier notif, PriceCalculator calc) {
this.repository = repo;
this.notifier = notif;
this.calculator = calc;
}
public void processOrder(Order order) {
// 計算は専門家に任せる
int total = calculator.calculateTax(order.getPrice());
order.setTotal(total);
// 相手が何者か知らなくても、「保存して」と頼むだけ
repository.save(order);
// 相手が何者か知らなくても、「通知して」と頼むだけ
notifier.send("注文完了");
}
}
このように1つのクラスに様々なものを集中させるのではなく、役割を分担することで保守性の高い良いアプリケーションになります。
委譲とコンポジション
委譲とコンポジションは先ほど説明した「継承」を使用するのはあまり良くないという発想から生まれた概念です。
以下の例を見てください。
// 親クラス(攻撃の機能を持っている)
class SwordAbility {
void attack() {
System.out.println("剣でズバッと斬る!");
}
}
// 勇者クラス
// 継承によって親の機能を「体の一部」として取り込む
class Hero extends SwordAbility {
void fight() {
// 親のメソッドをそのまま使う
super.attack();
}
}
// --- 実行 ---
Hero hero = new Hero();
hero.fight(); // "剣でズバッと斬る!"
このように勇者クラスを剣クラスから継承すると勇者は剣士にしかなれません。
ただし勇者の攻撃は弓もあれば呪文もあるかもしれません。
そのような場合、上記のように剣クラスを直接継承すると剣クラスしか使えないという状況に陥ります。
では、良い事例を見ていきます。
// 1. 武器の規格(インターフェース)
interface Weapon {
void use();
}
// 2. 具体的な武器たち
class Sword implements Weapon {
public void use() { System.out.println("剣でズバッと斬る!"); }
}
class Bow implements Weapon {
public void use() { System.out.println("弓でビュンと射る!"); }
}
// 3. 勇者クラス
class Hero {
// 【コンポジション】武器という「部品」を持つ(状態)
private Weapon currentWeapon;
public Hero(Weapon weapon) {
this.currentWeapon = weapon;
}
// 武器を交換するメソッド(継承ではこれができない!)
public void changeWeapon(Weapon newWeapon) {
this.currentWeapon = newWeapon;
}
public void fight() {
// 【委譲】「攻撃はお前の仕事だ」と武器に丸投げする(動作)
currentWeapon.use();
}
}
// --- 実行 ---
// 最初は剣を持たせる
Hero hero = new Hero(new Sword());
hero.fight(); // "剣でズバッと斬る!"
// 途中で弓に持ち替える!
hero.changeWeapon(new Bow());
hero.fight(); // "弓でビュンと射る!"
上記のような形で、直接継承するのではなく部品定義をした後に、部品の中の値を更新してあげると同じfightの関数で剣を使用するのか弓を使用するのか、などを切り分けることができます。
SOLIDの原則
ソフトウェアの開発や設計に用いられる考えとしてSOLIDの原則というものがあります。
このルールを守れば良いソフトウェアになるという基本的な考え方のことです。
SOLIDの原則を頭に入れてソースを書いていけば、以下のメリットがあります。
- 変更しやすい
- 変更に強い
- 理解しやすい
SOLIDの原則の定義を表にしてみました。
| 原則 | 正式名称 (英語) | 定義 (意味) | 目指す状態 (メリット) |
|---|---|---|---|
| S |
単一責任の原則 (Single Responsibility) |
クラスを変更する理由は、ひとつだけでなければならない |
高凝集 修正の影響範囲を限定できる |
| O |
開放閉鎖の原則 (Open/Closed) |
拡張に対しては開き、修正に対しては閉じているべきである |
拡張性 既存コードを壊さずに機能追加できる |
| L |
リスコフの置換原則 (Liskov Substitution) |
派生クラスは、基本クラスと置き換え可能でなければならない |
堅牢性 継承関係が正しく機能する |
| I |
インターフェース分離の原則 (Interface Segregation) |
クライアントに、使用しないメソッドへの依存を強制してはならない |
不要な依存の排除 関係ない機能変更の影響を受けない |
| D |
依存性逆転の原則 (Dependency Inversion) |
上位は下位に依存せず、抽象(インターフェース)に依存すべきである |
疎結合 部品交換やテストが容易になる |
「こういうのがあるのか~」と思いつつ、いつもイマイチピンとこないので、自分流に砕いて書いていきます。
S(単一責任の原則)
これは「一つのクラスには、一つの役割にしましょう」という原則です。
なぜ「一つの役割」に限定すべきかというと、複数の役割が同じクラスに存在すると、一つのロジックの修正が他のロジックの機能にまで影響する可能性があるからです。
具体例を見ていきましょう。
// 悪い例、複数のロジックが共存している
class OrderProcessor {
// メインの処理
public void processOrder(int price, int quantity) {
// 1. 計算関数を呼ぶ
int finalAmount = calculateTax(price, quantity);
// 2. DB保存関数を呼ぶ
saveToDatabase(finalAmount);
}
// --- 機能①:計算ロジック ---
private int calculateTax(int price, int quantity) {
int total = price * quantity;
return (int)(total * 1.1);
}
// --- 機能②:DB保存ロジック ---
// ここでエラーが出たり、ライブラリが変わると、
// このファイル全体(上の計算ロジック含む)がコンパイルエラーになるリスクがある
private void saveToDatabase(int amount) {
System.out.println("DB接続: Connect to MySQL...");
System.out.println("SQL実行: INSERT INTO orders VALUES (" + amount + ")");
}
}
// 良い例、クラスが分かれている
// 計算ロジック
class TaxCalculator {
public int calculate(int price, int quantity) {
int total = price * quantity;
return (int)(total * 1.1);
}
}
// 保存ロジック
class OrderRepository {
public void save(int amount) {
System.out.println("DB接続: Connect to MySQL...");
System.out.println("SQL実行: INSERT INTO orders VALUES (" + amount + ")");
}
}
// 使用するロジック
class OrderService {
TaxCalculator calculator = new TaxCalculator();
OrderRepository repository = new OrderRepository();
public void processOrder(int price, int quantity) {
int finalAmount = calculator.calculate(price, quantity);
repository.save(finalAmount);
}
}
どこかで見たことがあるコードだと思った方、よくぞお分かりになりました。
この単一責任の法則は先ほど解説した「凝集度」を高めようという考え方です。
どちらも中身を整理しようという類のものです。
O(開放閉鎖の原則)
開放閉鎖の原則は「機能追加はOK、機能変更はNG」という考え方です。
なぜ機能変更はNGかというと、既に出来上がっている機能を変更するということは、思わぬバグを仕込んでしまう恐れがあるためです。
実例を見ていきましょう。
決済処理について現金とカードの処理がある以下のようなソースがあったとします。
class PaymentProcessor {
// 決済処理
public void pay(String type) {
if (type.equals("CASH")) {
System.out.println("現金で支払いました");
} else if (type.equals("CREDIT")) {
System.out.println("カードで支払いました");
}
}
}
もしここに「PayPayも追加して!」と言われたら?
このPaymentProcessorクラスを変更するしかないですよね。
開放閉鎖の原則ではクラスの変更はNGです。
ではどうすれば良いかと言うと、以下のように記述します。
// まず、共通のインターフェースを作成します。
interface Payment {
void pay();
}
// 作成したインターフェースを用いてクラスを作成します。
// 現金払いクラス
class CashPayment implements Payment {
public void pay() {
System.out.println("現金で支払いました");
}
}
// カード払いクラス
class CreditPayment implements Payment {
public void pay() {
System.out.println("カードで支払いました");
}
}
// 使用するクラス
class PaymentProcessor {
public void process(Payment payment) {
payment.pay();
}
}
これなら仮にPayPayクラスの追加依頼があっても、同じインターフェースを活用して新たなクラスを作成すれば済みます。
開放閉鎖の原則は結合度を弱める法則と言えます。
L(リスコフの置換原則)
リスコフの置換原則とは、「サブタイプは、そのスーパータイプと置換可能でなければならない」という原則です。
サブタイプ:子クラスやインターフェースを実装して作ったクラス
スーパータイプ:親クラスやインターフェース
つまりリスコフの置換原則とは、「親クラスを使っている場所を、子クラスに置き換えたとしても、バグなどが発生しないようにしなければいけない」という原則です。
この原則に則ってソースを書かないと、機能追加などの改修を加えた時に、呼び出し元のコードが予期せぬエラーを吐くようになってしまいます。
具体例を見ていきましょう。
まずは悪い例から見ていきます。
この例では、子クラスが親クラスの決め事を守らなかったためにエラーが発生する仕組みとなっております。
// 親クラス
abstract class Employee {
abstract int calculatePay(); // 給料を計算する
}
// 子クラス
class Volunteer extends Employee {
@Override
int calculatePay() {
// 給料なんてないからエラーを吐かせる!
throw new RuntimeException("ボランティアに給料はありません!");
}
}
// 経理システム
List<Employee> allStaff = database.getAllStaff();
for (Employee e : allStaff) {
// 親クラスとして扱っているのに、ボランティアが来ると落ちる
int pay = e.calculatePay();
sendMoney(pay);
}
この例では、Volunteerクラスは給料計算が必要ないため、給料計算メソッドの中でエラーを吐いています。
しかし、経理システム側では「全ての従業員(Employee)」を取得してfor文を回しているため、ボランティアのユーザーの順番が来た時にエラーとなります。
正しい実装はどのような形かというと、以下のようになります。
// 1. 全員の共通点(名前など)を持つ親クラス
class Staff {
String name;
public Staff(String name) { this.name = name; }
}
// 2. 「給料計算ができる」という契約(インターフェース)
// これを持っている人だけにお金を払います
interface Payable {
int calculatePay();
}
// 3. 営業マン(スタッフであり、給料も出る)
class Salesman extends Staff implements Payable {
public Salesman(String name) { super(name); }
@Override
public int calculatePay() {
return 300000; // 給料計算ロジック
}
}
// 4. ボランティア(スタッフだけど、給料インターフェースは持たない!)
class Volunteer extends Staff {
public Volunteer(String name) { super(name); }
// calculatePay() は実装しない(そもそも Payable ではないので持っていない)
}
上記のように実装することによって、役職によって給料の支払いが必要か不要かを、型のレベルで分けることができました。
このようにリスコフの置換原則は、上位のクラスと下位のクラスが正しく置き換え可能になっていることによって、実装エラーや不具合を削減することができます。
I(インターフェース分離の原則)
インターフェース分離の原則(ISP: Interface Segregation Principle)とは、「インターフェースには最小限の機能を付与することが大事である」という原則です。
具体的に言うと、「クライアント(実装するクラス)に、不要なメソッドの実装を強制させない」ことが重要です。
具体例を見ていきましょう。 以下の事例は悪い例です。
// 巨大な「多機能プリンター」インターフェース
interface Printer {
void print(); // 印刷
void scan(); // スキャン
void fax(); // FAX
}
// 安物プリンター(印刷しかできない)クラス
// 本来「Printer」インターフェースを使いたいが…
class CheapPrinter implements Printer {
@Override
public void print() {
System.out.println("印刷します");
}
// ↓ 使えない機能まで実装を強制される!
@Override
public void scan() {
// 何もしない、またはエラーを出すしかない
throw new RuntimeException("スキャン機能はありません");
}
@Override
public void fax() {
// 安物なのでFAXもついていない…
throw new RuntimeException("FAX機能はありません");
}
}
このように、すべての機能を詰め込んだ大きなインターフェースを定義してしまうと、仮に「印刷機能のみ」を実装したい場合でも、スキャンとFAXについて「何もしない」あるいは「エラーを投げる」ような無駄な実装をする必要が出てしまいます。
しかし、インターフェースを機能ごとに細かく分離しておけば、印刷は印刷だけで使うし、スキャンはスキャンの時にだけ使えるというように、ロジックを整理することができます。
以下は良い事例です。
// ✅ ISP遵守:機能ごとにインターフェース(装備)を分ける
interface Printable {
void print();
}
interface Scannable {
void scan();
}
interface Faxable {
void fax();
}
分割することで、必要なインターフェースのみを選択して実装することができます。
ちなみに、名前に「able」を付与するのはインターフェース命名の慣習です。 インターフェースは「〜できる(能力)」を有することを表すため、そのような英語表現(Printable = 印刷できる)にするのが一般的です。
上記の良い事例を使用した実装は、以下の形になります。
// 安物プリンターは「印刷機能」だけを装備
class CheapPrinter implements Printable {
public void print() {
System.out.println("印刷します");
}
}
// 高級複合機は「全部」装備
class HighEndMachine implements Printable, Scannable, Faxable {
public void print() { /*...*/ }
public void scan() { /*...*/ }
public void fax() { /*...*/ }
}
このようにインターフェースを分けて実装することによって、クラス構成がシンプルになり、拡張性や保守性が向上します。
D(依存性逆転の原則)
最後に「依存性逆転の原則」について説明します。 これは、「上位モジュールは下位の具体的なクラスに依存するのではなく、インターフェースや抽象クラスに依存すべきである」という考え方です。
なぜ「逆転」と呼ばれるのかは、以下の図とコードを見比べるとよくわかります。
通常、何も意識せずにプログラムを書くと、上位モジュール(使う側)が下位モジュール(使われる側)を直接 new して利用します。
この時、依存の矢印は処理の流れと同じく「下」を向いています。
【 上位モジュール (OrderService) 】
┃
┃ ① 命令する (実行時の流れ)
⬇
┃
┃ ② 依存する (new している)
⬇ ★矢印がそのまま「下」を向いている!
┃
【 下位モジュール (CompanyAPay) 】
この状態で、例えば「A社の決済」を「B社の決済」に変更しようとすると、上位モジュールにまで修正の影響が及びます。
以下のコードでは、OrderService が CompanyAPay にガッチリと依存しているため、ここを書き換えない限り決済方法を変更できません。
class OrderService {
// ❌ A社専用のクラスを直接 new して依存している(密結合)
private CompanyAPay payment = new CompanyAPay();
public void checkout(int money) {
// ...在庫チェックなどの重要な処理...
// A社の独自メソッド「payCard」を知っている必要がある
payment.payCard(money);
}
}
ではどうすれば良いかと言うと、インターフェース(抽象) を間に挟んで、依存度を下げます。
こうすることで、A社からB社へ変更があったとしても、OrderService クラスを書き換える必要はなくなります。
// 1. インターフェース(共通ルール・抽象)
interface PaymentProcessor {
void pay(int money);
}
// 2. 注文処理(上位モジュール)
class OrderService {
private PaymentProcessor payment; // ❌ 具体的な会社名は知らない
// 外部から「PaymentProcessor」の実装をもらう(依存性の注入)
public OrderService(PaymentProcessor payment) {
this.payment = payment;
}
public void checkout(int money) {
// ...在庫チェック...
// インターフェースのメソッドを呼ぶだけ
payment.pay(money);
}
}
上記のように書くことで、依存関係の矢印の向きが変わりました。
図に表すと以下のようになります。
【 上位モジュール (OrderService) 】
┃
┃ ① 使う (依存)
⬇
【 インターフェース (PaymentProcessor) 】⬅ 契約・ルールのみ (抽象)
⬆
┃
┃ ② 実装する (implements)
┃ ★下位からの矢印が「上」を向いた!(逆転)
┃
【 下位モジュール (EmailSender / Payなど) 】
注目すべきは、下位モジュールからインターフェースに向かって矢印が上を向いている点です。
処理の流れ(上から下)に対し、依存の方向(下から上)が逆になったため、「依存性の逆転」と呼ばれます。
おわりに
今回はオブジェクト指向について詳しく見ていきました。
本当はデザインパターンについても解説していきたかったのですが、文量が多くなったためここまで。
改めて勉強してみて思ったのは、「ミスを減らそう」とか「読みやすくしよう」とか、「人に優しい設計にしようという考えで動いているんだなぁ」と思いました。
もっとこう、「速く動かそう!」とかそういう意識高い系の思想なものかと思ってました。
あと、コード量は少ない方がいいものだと考えてましたが、結果見やすくなったりミスを防げるならコード量は増えてもいいんだと気づきました。
ミスを減らすためとかなら頑張れそうなので、オブジェクト指向をより深く勉強していければなと思います。
株式会社BTMではエンジニアの採用をしております。
ご興味がある方はぜひコチラをご覧ください
