最近オブジェクト指向とデザインパターンについて学び始めたので、勉強しつつ記事にまとめていきたいと思います。
初回はSOLID原則についてです。SOLID原則はオブジェクト指向プログラミングにおいて、開発者にとって読みやすく、メンテナンスが可能なプログラムを作成しやすくするために考えられたルールです。
この記事では、オブジェクト指向プログラミングの重要な開発原則であるSOLID原則について皆さんが想像しやすいマリオのクラス実装を例に解説していきます。
クラスは単一の責任を持つべきと言う原則です。
ここでの責任というのは、オブジェクトが持っている機能のことです。
一つのクラスができる機能(責任)が複数あると、クラス内部の関数が強い結合を起こす可能性が高ま理望ましくありません。
次のマリオクラスを見てみましょう。
classMario{jump(){}private coin=0;getCoin(){this.coin+=1;}}
このMarioクラスには、マリオがジャンプする機能と、獲得コインを集計する機能の2つが存在します。しかし、獲得コインを集計する機能をMarioクラスが持つのは適切でしょうか?
例えば、ルイージというキャラクターもコインを集める場合、そのコインの獲得枚数はマリオクラスが管理すべきでしょうか?
このような問題は、Marioクラスが多すぎる責任を持っている(単一責任の原則に反している)ことが原因です。そこで、マリオの行動を表すクラスとコインの管理を担当するクラスとして分けてみましょう。
classMario{jump(){}}classCoinManager{private coin=0;getCoin(){this.coin+=1;}}
このように単一責任の原則に従ってクラスを分割すると、それぞれのクラスの役割が明確になり、機能の拡張もしやすくなります。
クラスは、拡張にはオープンで、変更にはクローズドであるべきという原則です。
つまり、クラスは既存のコードを変更せずに修正または追加できるように設計しなければならないということです。Characterクラスからマリオとルイージを実装していきます。
classCharacter{privatename: string;constructor(name){this.name= name;}}classCharacterAction{jump(character){if(character.name==="Mario"){return"Mario jumps 10 units!";}if(character.name==="Luigi"){return"Luigi jumps 12 units!";}// 新しいキャラクターが追加されるたびに、このメソッドを修正しなければならない}}const mario=newCharacter('Mario');const luigi=newCharacter('Luigi');const actionPerformer=newCharacterAction();console.log(actionPerformer.jump(mario));// Mario jumps 10 units!console.log(actionPerformer.jump(luigi));// Luigi jumps 12 units!
このコードでは、新しいキャラクターが追加されるたびにCharacterActionのjumpメソッドを修正しなければならないため、オープン・クローズドの原則に反しています。
オープン・クローズドの原則に従うためには、既存のコードを修正することなく新しいキャラクターを追加できるように設計する必要があります。
classCharacter{protectedname: string;constructor(name){this.name= name;}jump(){return`${this.name} jumps!`;}}// MarioクラスclassMarioextendsCharacter{constructor(){super("Mario");}jump(){return`${this.name} jumps 10 units!`;}}// LuigiクラスclassLuigiextendsCharacter{constructor(){super("Luigi");}jump(){return`${this.name} jumps 12 units!`;}}const mario=newMario();const luigi=newLuigi();console.log(mario.jump());// Mario jumps 10 units!console.log(luigi.jump());// Luigi jumps 12 units!
この設計において、新しいキャラクターを追加したい場合(例えば、ピーチ姫)は、新たにPeachクラスをCharacterクラスを継承して作成すればよく、既存のコード(MarioクラスやLuigiクラス)の修正は不要になります。
これにより、オープン・クローズドの原則に従った設計となります。
「親クラスのインスタンスが適用されるコードに対して、子クラスのインスタンスで置き換えても、問題なく動くべき」という原則です。
例としてマリオクラス(親)と、それを継承したヨッシークラス(子)を実装します。
classMario{protected jumpHeight=10;protected positionY=0;protected isOnTheGround=true;jump(){if(this.isOnTheGround){this.positionY+=this.jumpHeight;this.isOnTheGround=false;}}}classYoshiextendsMario{jump(){this.positionY+=this.jumpHeight;this.isOnTheGround=false;}}
上のコードを見ると、Marioクラス(親クラス)のjumpメソッドは、キャラクターが地上にいる場合のみジャンプを実行します。しかし、Yoshiクラス(子クラス)のjumpメソッドはこの制約を持っていません。つまり、Yoshiは地上にいなくてもジャンプができます。
この違いにより、MarioのインスタンスをYoshiのインスタンスで置き換えると、プログラムの振る舞いが変わる可能性があります。したがって、この設計はリスコフの置換原則に違反していると言えます。
interfaceCharacter{jump:()=>void;}classMarioimplementsCharacter{private jumpHeight=10;private positionY=0;private isOnTheGround=true;jump(){if(this.isOnTheGround){this.positionY+=this.jumpHeight;this.isOnTheGround=false;}}}classYoshiimplementsCharacter{private jumpHeight=10;private positionY=0;jump(){this.positionY+=this.jumpHeight;}}
ここでCharacterは単にジャンプの機能を提供することだけを要求する抽象的なインターフェイスです。
MarioとYoshiは共にCharacterインターフェースを実装しており、それぞれのjumpメソッドは正しく機能しています。この実装はリスコフの置換原則を満たしています。
不要なインターフェースに依存することを避けるべきという原則です。
以下ではActionインターフェイスを実装したマリオクラスとファイアマリオクラスです。
interfaceAction{move:()=>void;jump:()=>void;fire:()=>void;}classMarioimplementsAction{move(){console.log("走る");}jump(){console.log("ジャンプ");}fire(){console.log("ファイアは出せません");}}classFireMarioimplementsAction{move(){console.log("走る");}jump(){console.log("ジャンプ");}fire(){console.log("ファイア!");}}
Actionインターフェイスにfireメソッドが定義されています。しかしMarioはファイアを出せないので不要なfireメソッドを実装する必要があります。
もし、Marioクラスがfireという不要なメソッドを実装していることを知らない人がMarioクラスのfireメソッドを使ってしまうと問題が起きる可能性があります。
そこでMarioクラスが不要なfireメソッドを持たないようにインターフェイスを分離してみます。
interfaceAction{move:()=>void;jump:()=>void;}interfaceFireAction{fire:()=>void;}classMarioimplementsAction{move(){console.log("走る");}jump(){console.log("ジャンプ");}}classFireMarioimplementsAction,FireAction{move(){console.log("走る");}jump(){console.log("ジャンプ");}fire(){console.log("ファイア!");}}
上記の実装でMarioクラスが不要なfireメソッドを持つ必要がなくなり、インターフェイス分離の原則を満たすようになりました。
抽象(ビジネスロジック)が詳細(具体的な実装)に依存しないようにしようという原則です。
Characterクラスを継承したMarioクラスを実装するとします。
classCharacter{protected name;protected positionY=0;constructor(name: string){this.name= name;}jump(){if(this.name=="mario"){this.positionY+=10;}elseif(this.name=="luigi"){this.positionY+=15;}}}classMarioextendsCharacter{constructor(){super("mario");}}
上記のコードを見ると、Characterクラスは具体的なキャラクターの名前("mario"や"luigi")に依存していることが分かります。
Characterクラスのjumpメソッド内でキャラクターの名前に基づいて振る舞いを変更しています。
この設計は、高レベルのCharacterクラスが低レベルの詳細(具体的なキャラクター名)に依存しているため、依存性逆転の法則に違反しています。
abstractclassCharacter{protected positionY=0; abstractjump():void;}classMarioextendsCharacter{jump(){this.positionY+=10;}}classLuigiextendsCharacter{jump(){this.positionY+=15;}}
こちらのコードでは、Characterクラスはjumpメソッドを抽象メソッドとして定義し、具体的なキャラクターのクラス(MarioクラスやLuigiクラス)はその抽象メソッドをオーバーライドして独自の振る舞いを実装しています。これにより、基底クラスと派生クラスが互いに独立しており、依存性逆転の法則に従うようになりました。
この記事ではマリオクラスの実装を例にSOLID原則を解説しました!
完全に理解した気になっていても、アウトプットするとなると自分の理解が足りていないことに気付かされますね...
これからもオブジェクト指向とデザインバターンについてまとめていきますので、よかったら読んでみてください。
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。
1,2に対応する例はこんな感じ
constcreateCharactor=(name, units)=>({ name, units});constjump=(charactor)=>{return`${charactor.name} jumps${charactor.units} units!`;}const mario=createCharactor('Mario',10);const luigi=createCharactor('Luigi',12);console.log(jump(mario));// Mario jumps 10 units!console.log(jump(luigi));// Luigi jumps 12 units!
3に対応する例はこんな感じ
constcreateCharactor=(name, type, jumpHeight, positionY)=>({ name, type, jumpHeight, positionY});constisOnTheGround=(charactor)=>{return charactor.positionY===0;}constjump=(charactor)=>{if(charactor.type==='Mario'){if(isOnTheGround(charactor)){ charactor.positionY+= charactor.jumpHeight;}}elseif(charactor.type==='Yoshi'){ charactor.positionY+= charactor.jumpHeight;}else{thrownewError('jump charactor');}// return `${charactor.name} jumps ${charactor.jumpHeight} units!`;}const mario=createCharactor('MARIO','Mario',10,0);const luigi=createCharactor('YOSHI','Yoshi',12,0);
4に対応する例はこんな感じ
const mario={move:()=>{console.log("走る");},jump:()=>{console.log("ジャンプ");},}const fireMario={move:()=>{console.log("走る");},jump:()=>{console.log("ジャンプ");},fire:()=>{console.log("ファイア!");},}consthasFireball=(charactor)=>{returntypeof charactor.fire==='function'}
JS/TSは、パワーのある言語なのでオブジェクト指向に変に頼らずにシンプルに同じことが実現できます。
classやthisを混ぜ込むとコードの可読性が落ちるので使うべきじゃなく、
オブジェクト指向の継承は、どんな場面でもバッドパターンで暗黙的に分岐を隠蔽してしまうので、
例えば配列に全キャラクターのインスタンスが入っていて、jumpを呼び出している場合、全キャラクターのjumpを読まないと、不具合がないかどうか確認できないんだけど
jump関数の中に分岐を明確に書いていれば、読むコードはjump関数の中だけで済むので便利。
最近の関数型的なプログラミングの手法で、不具合を出しにくいコードの書き方です。
オブジェクト指向を超えたところまでいけば、シンプルさと高い可読性と不具合が減少した高い品質のコードを書ける、ということが、多くの人に知れ渡ったらいいのになー。と思い書いてみました。
超えるところまでいくには、全てを理解しないといけないのかもしれないけれども。
わかりやすい記事をありがとうございます。
来月、個人的にリスコフの置換原則の勉強会を開催予定でヒントをいただきました。
細かいですが1個、タイプミスと思われる箇所を見つけたので報告です。
現状>
ここでCarecterは単にジャンプの機能を提供することだけ
修正>
ここでCharacterは単にジャンプの機能を提供することだけ
参考になりました。
内容への指摘ではないので恐縮ですが、コードにシンタックスハイライトないと見にくいので、そこは改善してもらえるとありがたいです。
https://zenn.dev/zenn/articles/markdown-guide#コードブロック