概要
ソフトウェアエンジニアとして働き始めて早一年、それなりに大きなリポジトリのソースコードを扱ってきました。
様々なコードを読み書きする中で、とりあえず仕様を満たすコードを書くだけではなく、どのような設計・アーキテクチャにするかについて考え、勉強するようになりました。
この記事では、実務での経験を踏まえてクラスの継承を使う際の注意点について書いてみたいと思います。言語はRubyですが、他の言語を書いている方でも読めると思います。
※コードの内容は実際のものではなく、デフォルメしています。
想定読者
以下のような読者を想定しています。
- アプリケーションの設計やアーキテクチャに興味を持ち始めた人
- とりあえず仕様を満たす処理は書けるが、コードがすぐぐちゃぐちゃになってしまう人
- 個人で小さいアプリケーションを開発した経験はあるが、複数人で開発している比較的大きいアプリケーションには触れたことがない人
- プログラミング言語の基本的な文法は理解し、オブジェクト指向などを学んでいる人
家の情報を扱う
この記事ではサンプルとして、家(一戸建て)の情報を扱おうと思います。
まずは一戸建てを表すKodateクラスを定義してみます。
class Kodate
def initialize(address, house_age, station)
@address = address
@house_age = house_age
@station = station
end
def print_spec
puts "住所:#{@address}"
puts "築年数:築#{@house_age}年"
puts "最寄り駅:#{@station}駅"
end
end
Kodateクラスはインスタンス変数を3つ持っており、それぞれ住所、築年数、最寄り駅を表しています。
newでインスタンスを作成し、print_specメソッドを呼び出すことで、この家の情報を出力することができます。
kodate = Kodate.new('東京都新宿区', 5, '新宿')
kodate.print_spec
# => 住所:東京都新宿区
# 築年数:築5年
# 最寄り駅:新宿駅
ここまでは何の変哲もないコードです。
マンションを扱う
さてここで事業を展開することが決まり、一戸建てだけでなくマンションの情報も扱う必要が出てきたとします。
この時に何も考えず、「マンションは一戸建てと同じように住所、築年数、最寄り駅のインスタンス変数を持つな、、、ヨシっ!継承して再利用したれっ!」としてしまいました。
# Kodateを継承してMansionクラスを定義している
class Mansion < Kodate
def initialize(address, house_age, station, room_number)
super(address, house_age, station)
@room_number = room_number
end
def print_spec
super
puts "部屋番号:#{@room_number}号室"
end
end
ここでマンションは〇〇号室といった部屋番号を持つため、インスタンス変数を加えています。
また、部屋番号まで出力するためprint_specメソッドはオーバーライドしています。
Kodateクラスの時と同様にprint_specメソッドを呼んでみます。
mansion = Mansion.new('神奈川県横浜市', 10, '横浜', 102)
mansion.print_spec
# => 住所:神奈川県横浜市
# 築年数:築10年
# 最寄り駅:横浜駅
# 部屋番号:102号室
この段階では特にバグも無く家の情報を出力できています。
設計の崩壊
ここでさらに要件が増え、一戸建ての場合はローンの情報も出力することになったとします。
このあたりから変更が大変になり、不和が生じてきます。
class Kodate
def initialize(address, house_age, station, loan_yen)
@address = address
@house_age = house_age
@station = station
@loan_yen = loan_yen
end
def print_spec
puts "住所:#{@address}"
puts "築年数:築#{@house_age}年"
puts "最寄り駅:#{@station}駅"
puts "ローン:#{@loan_yen}円/月"
end
end
ローンのインスタンス変数loan_yenを加え、print_specメソッドにも手を加えました。
ここで一戸建てとマンション双方の情報を出力してみます。
一戸建ての情報は正常に出力できますが、、、
kodate = Kodate.new('東京都新宿区', 5, '新宿', 60000)
kodate.print_spec
# => 住所:東京都新宿区
# 築年数:築5年
# 最寄り駅:新宿駅
# ローン:60000円/月
マンションの場合は、インスタンス化の段階でエラーが発生してしまいます。
mansion = Mansion.new('神奈川県横浜市', 10, '横浜', 102)
# => この時点でArgumentErrorが発生
# mansion.print_spec
これは親となっているKodateクラスのinitializeメソッドの引数の数が変更されているにも関わらず、継承しているMansionクラス側でその変更に対応できていないことが原因です。
class Mansion < Kodate
def initialize(address, house_age, station, room_number)
super(address, house_age, station) # ここの引数が足りていない
@room_number = room_number
end
###
end
この問題自体はif文で分岐させたりすればひとまず解決はできますが、今後も変更があるたびに同様の問題が起こり続けそうです。
このような状態だと、変更が必要になった時にすぐに対応できず、テストも大変で開発効率が低下してしまいます。また、今回のようにパッと把握できるサイズのコードではそこまで問題がないように思えますが、複数人の開発者が長期的に触れるサイズの大きなコードだと、気づかない内にバグを埋め込む可能性が高くなります。
問題が起こるたびにその場で対応するのではなく、そもそもの設計から見直してみたいと思います。
問題点
上記のコードの問題点を一言で表すと、「抽象クラスではなく、具象クラスに依存してしまったこと」になります。
ここでいう具象クラスとは、一戸建てやマンションといった具体的なものを表し、詳細な処理を実際に担うクラスのことです。具象クラスはビジネス的な要求に応えて、日々頻繁に変更が入ります(ローンを追加してくれ、など)。この記事では、KodateクラスとMansionクラスは共に具象クラスです。
逆に抽象クラスとは、具体的なものの共通点を抜き出して抽象化した概念を表すクラスになります。具象クラスと比較して抽象クラスは変更が起こりにくいです。この記事では、この後の例で出てくるBuilding
クラスが抽象クラスにあたります。1
サンプルのコードを見ると、Mansionクラスのコードの中でKodateクラスに言及している、つまりKodateクラスを知っている状況になっています。このような状態をMansionクラスはKodateクラスに依存している、といいます。
class Mansion < Kodate # MansionはKodateを知っている(=依存している)
# 略
end
あるコードを変更したとき、多くの場合そのコードに依存しているコードにも変更が必要になります。今回の例だとMansionクラスはKodateクラスに依存しているので、Kodateクラスを変更した際にMansionクラスにも変更が必要になっていたわけです。このように変更が頻繁に起こる具象クラスに依存してしまうと、本来変更が必要ないはずのクラスにまで影響が広がるようになってしまいます。2
具象ではなく、抽象に依存せよ
これを解決するには「具象ではなく、抽象に依存する」ことが重要です。
今回の例では具象クラスであるKodateクラスを継承するのではなく、「一戸建て」や「マンション」といった具体的なものを抽象化した、「建物」という概念を表すBuildingクラスを定義し、この抽象度の高いクラスを継承すべきでした。(集合の積をとるイメージ)
これを図で表すと以下のようになります。(白抜きの矢印は依存していることを表します。)
実際にコードにしてみます。まず抽象的なBuildingクラスを定義します。
class Building
def initialize(address, house_age, station)
@address = address
@house_age = house_age
@station = station
end
def print_spec
puts "住所:#{@address}"
puts "築年数:築#{@house_age}年"
puts "最寄り駅:#{@station}駅"
end
end
これをそれぞれの具象クラスで継承します。これでKodateやMansionといった具象クラスがBuildingという抽象クラスに依存する形になります。
class Kodate < Building
def initialize(address, house_age, station, loan_yen)
super(address, house_age, station)
@loan_yen = loan_yen
end
def print_spec
super
puts "ローン:#{@loan_yen}円/月"
end
end
class Mansion < Building
def initialize(address, house_age, station, room_number)
super(address, house_age, station)
@room_number = room_number
end
def print_spec
super
puts "部屋番号:#{@room_number}号室"
end
end
これならKodate、Mansionどちらも正しく情報を出力できます。
kodate = Kodate.new('東京都新宿区', 5, '新宿', 60000)
kodate.print_spec
# => 住所:東京都新宿区
# 築年数:築5年
# 最寄り駅:新宿駅
# ローン:60000円/月
mansion = Mansion.new('神奈川県横浜市', 10, '横浜', 102)
mansion.print_spec
# => 住所:神奈川県横浜市
# 築年数:築10年
# 最寄り駅:横浜駅
# 部屋番号:102号室
さらにここに処理を追加していくことも容易です。KodateクラスとMansionクラスどちらも独立して改修が可能になっています。
まとめ
最初から全体像が見えているアプリケーションであれば、適切な設計を行うことはそこまで難しくないかもしれません。しかし実際に業務で携わってみると、開発を始めた初期段階では想定もしていなかった要件が後から出てきて(場合によっては上から降ってきて)、機能の追加を迫られることがほとんどです。そのような場合にすぐに変更ができるよう、依存関係は適切に保っておく必要があると思います。
今回のサンプルは極端な例ではありますが、実務の中で似たようなコードに遭遇する機会はよくあり、そのたびに問題になっています。納期やスケジュールに追われていると、どうしてもその場で思いついたとりあえずの実装で乗り切ろうとしてしまいがちです。継承を使う際には今ある実装をよく観察し、具象に依存していないか、共通点をより抽象度の高いクラスとして定義できないか、といった点を心がけ変更に強い良い実装をしていきましょう。3
参考文献
オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方
Clean Architecture 達人に学ぶソフトウェアの構造と設計
-
このあたりの概念はすぐに理解するのは難しく、正確に説明するのも難しいので詳しくは参考文献を参照してください。 ↩
-
個人的な意見ですが、依存される側からは何に依存されているのかが分からない、という観点も重要だと思っています。今回の例だと、Kodateクラスの中にはMansionクラスの情報は全く出てこないため、Kodateクラスだけを見ているとMansionクラスに依存されていることに気が付きません。この状態だと気がつかないまま依存されているクラスだけを変更してしまい、今回の例のようなバグに繋がります。実際、依存されていることに気がつかずリリースしてしまい、サービス停止などの障害につながる例はよく聞く話だと思います。依存関係をしっかり管理しておけば、抽象クラスを変更する際は多くのクラスに依存されていることが分かっているので、慎重に時間をかけて依存関係を調べるべき、そうでない場合はすぐに変更して大丈夫といった判断ができます。逆に何に依存されているか分からない状態では、具象クラスを少し変更するたびに、依存されている部分がないか検索・調査する必要があり、開発効率の低下に繋がると思います。 ↩
-
そもそも継承を使うべきではない、という話もあるようですが、この記事は継承を使うという前提でどういった心がけをするかという観点で書いています。 ↩