こんにちは、しが あきとし(@akitosihga)です。
先日あるMeetUpで良いコードの書き方について考える機会がありました。
『良いコード』の定義は幅広く様々な解釈があると思います。
その中でも、自分が敬愛するプログラマーのケント・ベックから学んだ事に焦点を当てて良いコードの書き方についてまとめました。
ケント・ベックとは
- テスト駆動開発(TDD)で有名なプログラマー
- アジャイル開発におけるエクストリームプログラミング(XP)の考案者としても有名
- アジャイル開発関連の書籍に度々登場するCRCを発明したのも彼だったりする
- 代表的な著書は「テスト駆動開発」「エクストリームプログラミング」
TDDのイメージが強い彼ですが、実はコーディングに対して並々ならぬ情熱を持っているのです。
彼の著書「実装パターン」では以下のように語っています。
『 70年の人生は、20億秒を少し超えるに過ぎない。誇りの持てない仕事で無駄にする時間はない。良いコードを書くこと自体が喜びであり、そのコードを他の人が理解し、評価し、使用し、拡張してくれればさらに喜びは増す。 』
職業人生をコーディングにかけている情熱的な人で自分は彼のそんな所が好きだったりします。
ケント・ベックの考える『良いコード』
ケントベックは『良いコード』の持つ価値を以下の3つと定めています。
- コミュニケーション
- シンプル
- 柔軟性
コミュニケーション
ケント・ベックは日々の開発をコードとプログラマーのコミュニケーションと捉えました。
コミュニケーションが取れるコードとは、読み手が理解できるコード、実装意図を正しく伝えらえるコードのことです。
コードは書いた時間より読まれる時間の方が長いです。
そのことから、人間とコミュニケーションが取れるコードは保守のコストを大きく減らすことができると言う点で大きな価値があります。
シンプル
シンプルとは『余分な複雑性』のないコードのことを指します。
『余分な複雑性』を持つコードとは、以下のようなものです。
- 動作に影響がないが冗長なコード
- 一生懸命動かそうとした痕跡のある整然としていないコード
例えば、機能が少しづつ育ってリファクタ可能だが着手できないコードは余分な複雑性がある状態のコードです。
ただし、複雑なコード全てが悪というわけではありません。
解決すべき問題が複雑な場合はその複雑さが反映されます。
これは『本質的な複雑性』を持つコードであり、プログラミングにおいてはその複雑性が『余分』なのか『本質的』なのかを判断する必要が生じます。
柔軟性
柔軟性のあるコードとは変更を容易に行えるコードのことです。
明日必要と思われた柔軟性も状況が変わって不要になってしまうことは皆さんも経験があるかもしれません。
こういったことから、凝った設計で備えるよりはシンプルと包括的なテストから得られる柔軟性の方が効果的です。
「YAGNI」・「KISS」の原則を尊重することでこの柔軟性に近づく事ができると自分は考えています。
この3つの価値に対してケント・ベックは『パターン』を用いてアプローチしました。
パターン
パターンとはソフトウェア設計上の課題とその有効な解決策を再利用可能なように一般化したものです。
GoFのデザインパターンは広く知られていると思います。
ケント・ベックはこのパターンの先駆けで、彼はこのパターンを用いて『良いコード』を書くことを推進しました。
ケント・ベックの用いるパターンの特徴は以下です。
- いずれもコミュニケーション・シンプル・柔軟性のいずれかの価値を反映している
- 粒度が細かく幅広い
- 一般的に想像されるパターンに加えて命名規則にもこのパターンの考えが用いられている
これらの特徴に加えて、どのパターンも後述の6つの原則に従っています。
この原則を守ることでコミュニケーション・シンプル・柔軟性の価値を生むことができるというのがケント・ベックの主張です。
次にこの6つの原則について紹介します。
パターンが従う6つの原則
原則1 結果の局所化
- コードの変更の結果が一箇所に留まるようにコードを構成すること
- コードの変更が想定外の場所に影響するとその変更コストは劇的に上昇する
- 結果の局所化ができているコードであれば、コード全体を把握しなくても段階的に理解すれば良くなる
原則2 繰り返しの最小化
- 同じ目的の下に同じ処理を行うコードは一箇所にまとめて共通化すること
- (当たり前だけど)コードが重複していると一つを変更すると他のすべても変更しなければいけなくなる
- 重複したコードの分だけ変更コストは増大する
- ただし、必ずしも重複を排除すれば良いのではなく、目的が異なれば同じ処理であってもコードは共通化しない方が良い
原則3 対称性
- 対称性を意識するとコードは劇的に読みやすくなる
- コードの粒度や行える操作を対称的にする(addメソッドがあれば、removeメソッドも実装する)
- クラス内のプロパティの生存期間を同一にする
- あるメソッドのグループがあれば全て同じ引数を取るようにする
- 長い処理のメソッドを均一の粒度に分割する
原則4 ロジックとデータの一体化
- ロジックとそのロジックが操作するデータは近くに置くこと
- 結果の局所化を遵守すると必然的にロジックとデータは一体化する
- 関連するロジックとデータは同じタイミングで変更することが多くなる
- これらが同じ場所にあれば変更の結果も局所化が保たれる
- ただし、ロジックとデータをどこに置くべきかは最初から明確ではない場合がある
- コードに対する継続的な責務の見直しは大事だなというのが個人的な所感
原則5 宣言型の表現
- 実装の詳細を書くよりこの処理が何をするかという意図を宣言する
- JavaScriptを例にすると、for文よりforEachメソッド
- Javaを例にすると、for文よりStream
- 命令型のプログラミングは制御とデータのフローを頭の中でイメージしながら読む必要がある
- 宣言型で表現されていれば読み手の認知負荷を大幅に抑えられる
原則6 変更頻度
- 変更されるタイミングが異なるロジックやデータは分けておく
- 変更されるタイミングが同じロジックやデータは同じ場所に置いておく
- クラス内のプロパティはなるべく一緒のタイミングで変更されるべき
- あるメソッドの実行中にだけ変更されるプロパティは、プロパティにせずローカル変数にすべき
税額計算のソフトウェアを例にすると...
一般的な計算ロジックのコードと、年ごとに固有なコードは一緒にせず分けておく
最後にパターンの例をいくつか紹介します。
パターンの例
サンプルコードはRubyで書いています。
馴染のない方向けに一般的なイディオムとは離れた書き方をしている箇所があります。
パターン1 Composed Method
# Before
# 商品の金額を計算する
class PriceCalculator
def discounted_total_price(order)
total = 0
order.items.each do |item|
total += item.price * item.quantity
end
if total > 100
total -= (total * 0.1)
end
if order.customer.premium_member?
total -= (total * 0.1)
end
return total.round()
end
end
# After
class PriceCalculator
def discounted_total_price(order)
total = total_price(order)
total = basic_discounted_price(total)
total = member_discounted_price(total)
return total.round()
end
private
def total_price(order)
return order.items.reduce(0) do |sum, item|
sum + item.price * item.quantity
end
end
def basic_discounted_price(total)
if total > 100
return total - (total * 0.1)
else
return total
end
end
def member_discounted_price(total, customer)
if order.customer.premium_member?
return total - (total * 0.1)
else
return total
end
end
end
プログラムを一つの事のみにするメソッドに分割しています。
これによりメソッド内部のメッセージは同じ抽象度に揃えられ以下の効果があります。
- メソッド内部の対称性が保たれる
- 宣言型で意図が表現されるため読みやすく、意図が伝わりやすくなる
Beforeではメソッドの処理を追わなければなりませんが、Afterではメソッドの呼び出しを見るだけで何をやっているか大まかな意図がわかるようになっています。
パターン2 Double Dispatch
# Before
class Member
attr_reader :rank
def initialize(rank)
@rank = rank
end
def rent_video(type)
if @rank == :premium
if type == :new_release
puts "Premium member renting a new release."
elsif type == :regular
puts "Premium member renting a regular video."
end
else
if type == :new_release
puts "Standard member cannot rent new releases."
elsif type == :regular
puts "Standard member renting a regular video."
end
end
end
end
member = Member.new(:premium)
member.rent_video(:new_release) # "Premium member renting a new release."
member2 = Member.new(:standard)
member2.rent_video(:new_release) # "Standard member cannot rent new releases."
# After
class Member
def initialize(rank)
@rank = rank
end
def rent_video(video_type)
rank_handler = case @rank
when :premium then PremiumRank.new
when :standard then StandardRank.new
else raise "Unknown member rank"
end
video_type_handler = case video_type
when :new_release then NewRelease.new
when :regular then RegularVideo.new
else raise "Unknown video type"
end
rank_handler.rent(video_type_handler)
end
end
class Rank
def rent(video_type)
raise NotImplementedError, "This method should be overridden by subclasses"
end
end
class VideoType
def rent_by_premium
raise NotImplementedError, "This method should be overridden by subclasses"
end
def rent_by_standard
raise NotImplementedError, "This method should be overridden by subclasses"
end
end
# After
class PremiumRank < Rank
def rent(video_type)
video_type.rent_by_premium
end
end
class StandardRank < Rank
def rent(video_type)
video_type.rent_by_standard
end
end
class NewRelease < VideoType
def rent_by_premium
puts "Premium member renting a new release."
end
def rent_by_standard
puts "Standard member cannot rent new releases."
end
end
class RegularVideo < VideoType
def rent_by_premium
puts "Premium member renting a regular video."
end
def rent_by_standard
puts "Standard member renting a regular video."
end
end
class Member
attr_reader :rank
def initialize(rank)
@rank = rank
end
def rent_video(type)
@rank.rent(type)
end
end
PremiumRank.new.rent(NewRelease.new) # "Premium member renting a new release."
StandardRank.new.rent(NewRelease.new) # "Standard member cannot rent new releases."
PremiumRank.new.rent(RegularVideo.new) # "Premium member renting a regular video."
StandardRank.new.rent(RegularVideo.new) # "Standard member renting a regular video."
多態性を用いて異なる2つの関心事の変更頻度を分割することで、冗長な『余分な複雑性』の排除を行っています。
パターン3 Method Object
# Before
class Order
def calculate_total_price(customer, items, discount,
tax_rate, shipping_cost, coupon, loyalty_points)
# ベースの合計金額を計算
total = items.sum(&:price)
# アイテムが10個以上なら追加割引
total *= 0.95 if items.size >= 10
# その他すごく複雑な処理が続く...
total
end
def other_method
# 他の処理
end
# その他メソッドが続く...
end
order = Order.new
puts order.calculate_total_price(customer, items, 10, 0.08, 5, coupon, loyalty_points)
# After
class Calculator
attr_reader :customer, :items, :discount, :tax_rate,
:shipping_cost, :coupon, :loyalty_points
def initialize(customer, items, discount, tax_rate,
shipping_cost, coupon, loyalty_points)
@customer = customer
@items = items
@discount = discount
@tax_rate = tax_rate
@shipping_cost = shipping_cost
@coupon = coupon
@loyalty_points = loyalty_points
end
def calculate_total_price
# ベースの合計金額を計算
total = items.sum(&:price)
# アイテムが10個以上なら追加割引
total *= 0.95 if items.size >= 10
# その他すごく複雑な処理が続く...
total
end
end
class Order
attr_reader :calculator
def calculate_total_price
calculator.calculate_total_price
end
def other_method
# 他の処理
end
# その他メソッドが続く...
end
order = Order.new
Order.calculator = Calculator.new(customer, items, 10, 0.08, 5, coupon, loyalty_points)
puts order.calculate_total_price
Composed Methodでも単純化できないほど大きいメソッドは、そのメソッド自体をオブジェクトにします。
関連するデータをプロパティとして持つことでロジックとデータを一体化しています。
多くの引数を持つ場合、メソッドから引数がなくなることで可読性も向上します。
Composed Methodと組み合わせて使うことでより明確でシンプルなコードになります。
まとめ
-
ケント・ベックの考える『良いコード』はコミュニケーション・シンプル・柔軟性から成り立っている
-
パターンを積極的に再利用することで効率的で的確なコーディングを行っている
-
ケント・ベックのパターンは6つの原則に従っている
- 結果の局所化
- 繰り返しの最小化
- 対称性
- ロジックとデータの一体化
- 宣言型の表現
- 変更頻度
-
本日紹介したパターンやその他のパターンを学んだり、普段コーディングを行う中でパターンを発見することは良いコードを書くことに繋がっていく
-
普段コードを書く中でパターンの6つの原則を意識するのも効果的