Javaにおけるリスコフの置換原則
1. 概要
SOLID設計原則はRobertCによって導入されました。 マーティンは2000年の論文、 Design Principles and DesignPatternsで。 SOLIDの設計原則は、より保守しやすく、理解しやすく、柔軟なソフトウェアを作成するのに役立ちます。
この記事では、頭字語の「L」であるリスコフの置換原則について説明します。
2. オープン/クローズド原則
リスコフの置換原則を理解するには、最初に開放/閉鎖原則(SOLIDの「O」)を理解する必要があります。
オープン/クローズド原則の目標は、ソフトウェアを設計することを奨励しているため、新しいコードを追加するだけで新しい機能を追加できます。 これが可能な場合は、緩く結合されているため、保守が容易なアプリケーションです。
3. ユースケースの例
オープン/クローズド原則をもう少し理解するために、銀行アプリケーションの例を見てみましょう。
3.1. オープン/クローズド原則なし
私たちの銀行アプリケーションは、「現在」と「貯蓄」の2つのアカウントタイプをサポートしています。 これらは、それぞれクラスCurrentAccountおよびSavingsAccountによって表されます。
BankingAppWithdrawalService は、ユーザーに引き出し機能を提供します。
残念ながら、この設計の拡張には問題があります。 BankingAppWithdrawalService は、account の2つの具体的な実装を認識しています。したがって、 BankingAppWithdrawalService は、新しいアカウントタイプが導入されるたびに変更する必要があります。
3.2. オープン/クローズド原則を使用してコードを拡張可能にする
オープン/クローズの原則に準拠するようにソリューションを再設計しましょう。 代わりにAccount基本クラスを使用して、新しいアカウントタイプが必要になったときに、BankingAppWithdrawalServiceを変更から閉じます。
ここでは、CurrentAccountとSavingsAccountが拡張する新しい抽象Accountクラスを紹介しました。
BankingAppWithdrawalService は、具体的なアカウントクラスに依存しなくなりました。 現在は抽象クラスのみに依存しているため、新しいアカウントタイプが導入されたときに変更する必要はありません。
したがって、 BankingAppWithdrawalService は、新しいアカウントタイプの拡張機能に対してオープンですが、変更のために閉じられています。統合するため。
3.3. Javaコード
Javaでこの例を見てみましょう。 まず、Accountクラスを定義しましょう。
public abstract class Account {
protected abstract void deposit(BigDecimal amount);
/**
* Reduces the balance of the account by the specified amount
* provided given amount > 0 and account meets minimum available
* balance criteria.
*
* @param amount
*/
protected abstract void withdraw(BigDecimal amount);
}
そして、BankingAppWithdrawalServiceを定義しましょう。
public class BankingAppWithdrawalService {
private Account account;
public BankingAppWithdrawalService(Account account) {
this.account = account;
}
public void withdraw(BigDecimal amount) {
account.withdraw(amount);
}
}
ここで、この設計で、新しいアカウントタイプがリスコフの置換原則にどのように違反する可能性があるかを見てみましょう。
3.4. 新しいアカウントタイプ
銀行は現在、顧客に高金利の定期預金口座を提供したいと考えています。
これをサポートするために、新しいFixedTermDepositAccountクラスを導入しましょう。 実世界の定期預金口座は「a」タイプの口座です。 これは、オブジェクト指向設計における継承を意味します。
それでは、FixedTermDepositAccountをAccountのサブクラスにしましょう。
public class FixedTermDepositAccount extends Account {
// Overridden methods...
}
ここまでは順調ですね。 ただし、銀行は定期預金口座の引き出しを許可したくありません。
これは、新しい FixedTermDepositAccount クラスが、Accountが定義するwithdrawメソッドを意味のある形で提供できないことを意味します。 これに対する一般的な回避策の1つは、FixedTermDepositAccountが実行できないメソッドでUnsupportedOperationExceptionをスローするようにすることです。
public class FixedTermDepositAccount extends Account {
@Override
protected void deposit(BigDecimal amount) {
// Deposit into this account
}
@Override
protected void withdraw(BigDecimal amount) {
throw new UnsupportedOperationException("Withdrawals are not supported by FixedTermDepositAccount!!");
}
}
3.5. 新しいアカウントタイプを使用したテスト
新しいクラスは正常に機能しますが、BankingAppWithdrawalServiceで使用してみましょう。
Account myFixedTermDepositAccount = new FixedTermDepositAccount();
myFixedTermDepositAccount.deposit(new BigDecimal(1000.00));
BankingAppWithdrawalService withdrawalService = new BankingAppWithdrawalService(myFixedTermDepositAccount);
withdrawalService.withdraw(new BigDecimal(100.00));
当然のことながら、バンキングアプリケーションは次のエラーでクラッシュします。
Withdrawals are not supported by FixedTermDepositAccount!!
オブジェクトの有効な組み合わせがエラーになる場合は、この設計に明らかに問題があります。
3.6. 何が悪かったのか?
BankingAppWithdrawalService は、Accountクラスのクライアントです。 Account とそのサブタイプの両方が、Accountクラスがwithdrawメソッドに指定した動作を保証することを期待しています。
/**
* Reduces the account balance by the specified amount
* provided given amount > 0 and account meets minimum available
* balance criteria.
*
* @param amount
*/
protected abstract void withdraw(BigDecimal amount);
ただし、 withdraw メソッドをサポートしていないため、 FixedTermDepositAccount はこのメソッド仕様に違反しています。したがって、をFixedTermDepositAccountに確実に置き換えることはできません。 ]アカウント。
つまり、FixedTermDepositAccountはリスコフの置換原則に違反しています。
3.7. BankingAppWithdrawalService のエラーを処理できませんか?
アカウントのwithdrawメソッドのクライアントが、呼び出し時に発生する可能性のあるエラーを認識しなければならないように、設計を修正することができます。 ただし、これは、クライアントが予期しないサブタイプの動作について特別な知識を持っている必要があることを意味します。 これは、オープン/クローズの原則を破り始めます。
言い換えると、オープン/クローズド原則が適切に機能するためには、すべてのサブタイプが、クライアントコードを変更することなく、スーパータイプの代わりに使用できる必要があります。 リスコフの置換原則を順守することで、この置換可能性が保証されます。
ここで、リスコフの置換原則について詳しく見ていきましょう。
4. リスコフの置換原則
4.1. 意味
ロバートC。 マーティンはそれを要約します:
サブタイプは、基本タイプの代わりに使用できる必要があります。
バーバラ・リスコフは、1988年にそれを定義し、より数学的な定義を提供しました。
タイプSのオブジェクトo1ごとに、タイプTのオブジェクトo2があり、Tに関して定義されたすべてのプログラムPについて、o1をo2に置き換えても、Pの動作は変わらない場合、SはTのサブタイプになります。
これらの定義をもう少し理解しましょう。
4.2. サブタイプはいつそのスーパータイプの代わりになりますか?
サブタイプは、そのスーパータイプの代わりに自動的に使用できるようにはなりません。 置換可能であるためには、サブタイプはそのスーパータイプのように動作する必要があります。
オブジェクトの動作は、クライアントが信頼できるコントラクトです。 動作は、パブリックメソッド、入力に課せられた制約、オブジェクトが通過する状態の変化、およびメソッドの実行による副作用によって指定されます。
Javaでのサブタイピングには、基本クラスのプロパティが必要であり、メソッドはサブクラスで使用できます。
ただし、振る舞いサブタイピングは、サブタイプがスーパータイプのすべてのメソッドを提供するだけでなく、スーパータイプの振る舞い仕様に準拠する必要があることを意味します。 これにより、スーパータイプの動作に関してクライアントが行ったすべての仮定がサブタイプによって満たされることが保証されます。
これは、リスコフの置換原則がオブジェクト指向設計にもたらす追加の制約です。
ここで、銀行アプリケーションをリファクタリングして、以前に発生した問題に対処しましょう。
5. リファクタリング
銀行の例で見つかった問題を修正するために、根本的な原因を理解することから始めましょう。
5.1. 根本的な原因
この例では、FixedTermDepositAccountはAccountの動作サブタイプではありませんでした。
アカウントの設計では、すべてのアカウントタイプで引き出しが許可されていると誤って想定されていました。 その結果、引き出しをサポートしない FixedTermDepositAccount を含むアカウントのすべてのサブタイプは、withdrawメソッドを継承しました。
アカウントの契約を延長することでこれを回避することはできますが、別の解決策があります。
5.2. 改訂されたクラス図
アカウント階層を別の方法で設計しましょう。
すべてのアカウントが引き出しをサポートしていないため、withdrawメソッドをAccountクラスから新しい抽象サブクラスWithdrawableAccountに移動しました。 CurrentAccountとSavingsAccountの両方で引き出しが可能です。 そのため、これらは新しいWithdrawableAccountのサブクラスになりました。
これは、 BankingAppWithdrawalService が適切なタイプのアカウントを信頼して、withdraw機能を提供できることを意味します。
5.3. リファクタリングされたBankingAppWithdrawalService
BankingAppWithdrawalService は、 WithdrawableAccount :を使用する必要があります。
public class BankingAppWithdrawalService {
private WithdrawableAccount withdrawableAccount;
public BankingAppWithdrawalService(WithdrawableAccount withdrawableAccount) {
this.withdrawableAccount = withdrawableAccount;
}
public void withdraw(BigDecimal amount) {
withdrawableAccount.withdraw(amount);
}
}
FixedTermDepositAccount については、Accountを親クラスとして保持します。 その結果、確実に実行できる預金の動作のみを継承し、不要なwithdrawメソッドを継承しなくなります。 この新しい設計は、以前に見た問題を回避します。
6. ルール
次に、メソッドのシグネチャ、不変条件、前提条件、および事後条件に関するいくつかのルール/手法を見てみましょう。これらは、正常に動作するサブタイプを確実に作成するために使用できます。
彼らの著書Javaでのプログラム開発:抽象化、仕様、およびオブジェクト指向設計で、BarbaraLiskovとJohnGuttagは、これらのルールを署名ルール、プロパティルール、およびメソッドルールの3つのカテゴリにグループ化しました。
これらのプラクティスのいくつかは、Javaのオーバーライドルールによってすでに適用されています。
ここでいくつかの用語に注意する必要があります。 ワイドタイプの方が一般的です。たとえば、 Object は、任意のJavaオブジェクトを意味し、たとえば CharSequence よりも幅が広くなります。ここで、 String は非常に具体的であるため、幅が狭くなります。 。
6.1. シグニチャルール–メソッド引数タイプ
このルールは、オーバーライドされたサブタイプメソッドの引数タイプは、スーパータイプメソッドの引数タイプと同一または幅が広い可能性があることを示しています。
Javaのメソッドオーバーライドルールは、オーバーライドされたメソッド引数タイプがスーパータイプメソッドと完全に一致するように強制することにより、このルールをサポートします。
6.2. 署名ルール–リターンタイプ
オーバーライドされたサブタイプメソッドの戻りタイプは、スーパータイプメソッドの戻りタイプよりも狭くなる可能性があります。 これは、戻り型の共分散と呼ばれます。 共分散は、スーパータイプの代わりにサブタイプが受け入れられるタイミングを示します。 Javaは、戻り型の共分散をサポートします。 例を見てみましょう:
public abstract class Foo {
public abstract Number generateNumber();
// Other Methods
}
FooのgenerateNumberメソッドの戻り値は、Numberです。 より狭いタイプのIntegerを返すことにより、このメソッドをオーバーライドしてみましょう。
public class Bar extends Foo {
@Override
public Integer generateNumber() {
return new Integer(10);
}
// Other Methods
}
Integer IS-A Number であるため、 Number を期待するクライアントコードは、FooをBarに置き換えることができます。問題。
一方、 Bar のオーバーライドされたメソッドが、 Number よりも広い型を返す場合、たとえば Object 、Objectの任意のサブタイプを含む可能性があります。 トラック。 Number の戻りタイプに依存するクライアントコードは、Truckを処理できませんでした。
幸い、Javaのメソッドオーバーライドルールは、オーバーライドメソッドがより広い型を返すのを防ぎます。
6.3. 署名規則–例外
サブタイプメソッドは、スーパータイプメソッドよりも少ないまたは狭い(ただし、追加または広い)例外をスローできません。
クライアントコードがサブタイプを置き換えると、スーパータイプメソッドよりも少ない例外をスローするメソッドを処理できるため、これは理解できます。 ただし、サブタイプのメソッドが新しいまたはより広範なチェック済み例外をスローすると、クライアントコードが破損します。
Javaのメソッドオーバーライドルールは、チェックされた例外に対してこのルールをすでに適用しています。 ただし、Javaのオーバーライドメソッドは、オーバーライドされたメソッドが例外を宣言しているかどうかに関係なく、 RuntimeExceptionをスローできます。
6.4. プロパティルール–クラス不変条件
class invariant は、オブジェクトのすべての有効な状態に対して真でなければならないオブジェクトプロパティに関するアサーションです。
例を見てみましょう:
public abstract class Car {
protected int limit;
// invariant: speed < limit;
protected int speed;
// postcondition: speed < limit
protected abstract void accelerate();
// Other methods...
}
Car クラスは、speedが常にlimitを下回っている必要があるというクラス不変条件を指定します。 不変条件ルールは、すべてのサブタイプメソッド(継承および新規)は、スーパータイプのクラス不変条件を維持または強化する必要があると述べています。
クラス不変条件を保持するCarのサブクラスを定義しましょう。
public class HybridCar extends Car {
// invariant: charge >= 0;
private int charge;
@Override
// postcondition: speed < limit
protected void accelerate() {
// Accelerate HybridCar ensuring speed < limit
}
// Other methods...
}
この例では、 Car の不変条件は、HybridCarのオーバーライドされたaccelerateメソッドによって保持されます。 HybridCar は、独自のクラス不変 Charge> = 0 を追加で定義し、これは完全に問題ありません。
逆に、クラス不変条件がサブタイプによって保持されていない場合、スーパータイプに依存するクライアントコードを壊します。
6.5. プロパティルール–履歴の制約
履歴制約は、サブクラス メソッド(継承または新規)が、基本クラスが許可しなかった状態変更を許可してはならないことを示しています。
例を見てみましょう:
public abstract class Car {
// Allowed to be set once at the time of creation.
// Value can only increment thereafter.
// Value cannot be reset.
protected int mileage;
public Car(int mileage) {
this.mileage = mileage;
}
// Other properties and methods...
}
Car クラスは、mileageプロパティの制約を指定します。 mileage プロパティは、作成時に1回だけ設定でき、それ以降はリセットできません。
次に、 Car:を拡張するToyCarを定義しましょう。
public class ToyCar extends Car {
public void reset() {
mileage = 0;
}
// Other properties and methods
}
ToyCar には、mileageプロパティをリセットする追加のメソッドresetがあります。 その際、 ToyCar は、mileageプロパティに親が課した制約を無視しました。 これにより、制約に依存するクライアントコードが壊れます。 したがって、ToyCarはCarの代わりにはなりません。
同様に、基本クラスに不変のプロパティがある場合、サブクラスはこのプロパティの変更を許可しないでください。 これが、不変クラスがfinalである必要がある理由です。
6.6. メソッドルール–前提条件
メソッドを実行する前に、前提条件を満たす必要があります。 パラメータ値に関する前提条件の例を見てみましょう。
public class Foo {
// precondition: 0 < num <= 5
public void doStuff(int num) {
if (num <= 0 || num > 5) {
throw new IllegalArgumentException("Input out of range 1-5");
}
// some logic here...
}
}
ここで、 doStuff メソッドの前提条件は、 numパラメーター値が1から5の間でなければならないことを示しています。 メソッド内の範囲チェックを使用して、この前提条件を適用しました。 サブタイプは、オーバーライドするメソッドの前提条件を弱めることができます(ただし、強めることはできません)。 サブタイプが前提条件を弱めると、スーパータイプメソッドによって課せられる制約が緩和されます。
ここで、 doStuff メソッドを、弱められた前提条件でオーバーライドしてみましょう。
public class Bar extends Foo {
@Override
// precondition: 0 < num <= 10
public void doStuff(int num) {
if (num <= 0 || num > 10) {
throw new IllegalArgumentException("Input out of range 1-10");
}
// some logic here...
}
}
ここでは、オーバーライドされた状態で前提条件が弱められます doStuff 方法 0
逆に、サブタイプが前提条件を強化する場合(例: 0
これは、この新しいより厳しい制約を予期しないクライアントコードを壊します。
6.7. メソッドルール–事後条件
postcondition は、メソッドの実行後に満たす必要のある条件です。
例を見てみましょう:
public abstract class Car {
protected int speed;
// postcondition: speed must reduce
protected abstract void brake();
// Other methods...
}
ここで、Carのbrakeメソッドは、メソッド実行の最後にCarのspeedを減速する必要があるという事後条件を指定します。 サブタイプは、オーバーライドするメソッドの事後条件を強化できます(ただし、弱めることはできません)。 サブタイプが事後条件を強化するとき、それはスーパータイプメソッド以上のものを提供します。
次に、この前提条件を強化するCarの派生クラスを定義しましょう。
public class HybridCar extends Car {
// Some properties and other methods...
@Override
// postcondition: speed must reduce
// postcondition: charge must increase
protected void brake() {
// Apply HybridCar brake
}
}
HybridCarでオーバーライドされたbrakeメソッドは、 Charge も確実に増加するようにすることで、事後条件を強化します。 したがって、Carクラスのbrakeメソッドの事後条件に依存するクライアントコードは、Carの代わりにHybridCarを使用しても違いはありません。 。
逆に、 HybridCar がオーバーライドされたブレーキメソッドの事後条件を弱める場合、速度が低下することは保証されなくなります。 これにより、Carの代わりにHybridCarを指定すると、クライアントコードが破損する可能性があります。
7. コードの臭い
現実の世界でそのスーパータイプの代わりに使用できないサブタイプをどのように見つけることができますか?
リスコフの置換原則の違反の兆候であるいくつかの一般的なコードの臭いを見てみましょう。
7.1. サブタイプは、実行できない動作の例外をスローします
この例は、前の銀行アプリケーションの例で見ました。
リファクタリングの前に、AccountクラスにはそのサブクラスFixedTermDepositAccountが望まない追加のメソッドwithdrawがありました。 FixedTermDepositAccount クラスは、withdrawメソッドに対してUnsupportedOperationExceptionをスローすることで、これを回避しました。 ただし、これは継承階層のモデリングの弱点をカバーするための単なるハックでした。
7.2. サブタイプは、実行できない動作の実装を提供しません
これは、上記のコードの臭いのバリエーションです。 サブタイプは動作を実行できないため、オーバーライドされたメソッドでは何も実行されません。
これが例です。 FileSystemインターフェースを定義しましょう。
public interface FileSystem {
File[] listFiles(String path);
void deleteFile(String path) throws IOException;
}
FileSystem:を実装するReadOnlyFileSystemを定義しましょう。
public class ReadOnlyFileSystem implements FileSystem {
public File[] listFiles(String path) {
// code to list files
return new File[0];
}
public void deleteFile(String path) throws IOException {
// Do nothing.
// deleteFile operation is not supported on a read-only file system
}
}
ここで、 ReadOnlyFileSystemはdeleteFile操作をサポートしていないため、実装を提供していません。
7.3. クライアントはサブタイプについて知っています
クライアントコードがinstanceofまたはダウンキャストを使用する必要がある場合は、オープン/クローズ原則とリスコフの置換原則の両方に違反している可能性があります。
FilePurgingJobを使用してこれを説明しましょう。
public class FilePurgingJob {
private FileSystem fileSystem;
public FilePurgingJob(FileSystem fileSystem) {
this.fileSystem = fileSystem;
}
public void purgeOldestFile(String path) {
if (!(fileSystem instanceof ReadOnlyFileSystem)) {
// code to detect oldest file
fileSystem.deleteFile(path);
}
}
}
FileSystem モデルは基本的に読み取り専用ファイルシステムと互換性がないため、ReadOnlyFileSystemはサポートできないdeleteFileメソッドを継承します。 このサンプルコードは、 instanceof チェックを使用して、サブタイプの実装に基づいて特別な作業を行います。
7.4. サブタイプメソッドは常に同じ値を返します
これは他の違反よりもはるかに微妙な違反であり、見つけるのが困難です。 この例では、ToyCarは常にremainingFuelプロパティの固定値を返します。
public class ToyCar extends Car {
@Override
protected int getRemainingFuel() {
return 0;
}
}
これはインターフェースとその値の意味によって異なりますが、一般に、オブジェクトの変更可能な状態値をハードコーディングすることは、サブクラスがそのスーパータイプ全体を満たしていないこと、およびそのスーパータイプを実際に置き換えることができないことを示しています。
8. 結論
この記事では、LiskovSubstitutionSOLIDの設計原理について説明しました。
リスコフの置換原則は、優れた継承階層をモデル化するのに役立ちます。 これは、オープン/クローズの原則に準拠していないモデル階層を防ぐのに役立ちます。
リスコフの置換原則に準拠する継承モデルは、暗黙的に開放/閉鎖原則に従います。
まず、オープン/クローズの原則に従おうとするが、リスコフの置換原則に違反するユースケースを検討しました。 次に、リスコフの置換原則の定義、振る舞いサブタイピングの概念、およびサブタイプが従わなければならない規則について説明しました。
最後に、既存のコードの違反を検出するのに役立つ一般的なコードの臭いをいくつか調べました。
いつものように、この記事のサンプルコードは、GitHubから入手できます。