はじめに
早速ですが、みなさんは以下の仕様を実現するためにどのように実装しますか?少し考えてみてください。
仕様
- 無課金で半年以上継続しているユーザーは10%off、課金で3ヶ月以上継続しているユーザーは5%offのキャンペーンをします
- キャンペーンの画面はメイン画面から購入の際に遷移します
- 5%offと10%offで同一画面ですが表示される文言が異なります
- ユーザーの判断にはAPIで受け取ったCampaignTypeをもとに判断します
- CampaignTypeは3種類あり、起動時に取得・保持しています
- ineligible
- nomalDiscount
- largeDiscount
考えるポイント
1. レスポンスの扱い方
レスポンスはjsonでString型だろうから、クライアントで扱うときにどう持とう?
2. メイン画面からの遷移の制御
メイン画面から遷移する時にキャンペーンの対象のユーザーとそうでないユーザーとで制御を入れなきゃいけないよね。
3. キャンペーン画面でのTypeの扱い方
対象ではないユーザー(CampaignType.ineligible
)はこの画面に入ってこないはずだけど、CampaignType
をそのまま画面で扱うとineligible
も気にしなければいけなくなってしまうがどうしよう?
みなさんが考えた実装をもとにこの先のお話を、「自分だったらどうしよう?」という観点で読んでいってください!
本題へ
この先はみんな大好き「達人プログラマー 第2版」を大いに参考にし、防御的・攻撃的・契約的プログラミングの観点から上記の実装について、解説を交えつつ考えていきます。
私の考えていることは決して正解などではなく、「こういう考え方もあるよね。」という一つの意見に過ぎません。まだまだ勉強中の身なので暖かい目で見つつ、鵜呑みにせず参考にしていただけると嬉しいです。
防衛的プログラミング
達人プログラマーの中では防衛的と書かれていますが、防御的とも表現されます。
防衛的プログラミングとは
「そうなるはずだ」と決め付けないことである。
...
防御的プログラミングの根底にあ るのは、ルーチンに不正なデータが渡されたときに、それが他のルーチンのせいであったとしても、被害を受けないようにすることだ。
もう少し一般的に言うと、プログラムには必ず問題があり、プログラムは変更されるものであり、賢いプログラマはそれを踏まえてコードを開発する、という認識を持つことである。
CODE COMPLETE 第2版 第8章
これを見ると全ての入力に対して信頼せずにチェックすることだけが防衛だと思ってしまいますが、防衛手段はそれだけではありません。
プログラムの状態をこまめにチェックし、おかしな状態になってしまう前に停止させる
達人プログラマー 第2版 第4章
プログラムの状態をこまめにチェックし、バグを早期に発見するというのも防衛手段です。コードの整合性の話だけでなく、こんな観点も存在します。
メモリー、ファイル、デバイスといったシステムリソースをやりくりする必要があるはずです。
達人プログラマー 第2版 第4章
これも大切な防衛の観点です。システムリソースのやりくりに失敗すると、ユーザーの体験を著しく低下させるだけでなくシステムが停止する危険があります。
このように、防衛的なプログラミングというと、入力のデータを信頼せず整合性を確認することだけと思ってしまいがちですが、防衛の観点は多くあります
実装を考えてみる
ではさっきの例の「キャンペーン画面でユーザーによって描画を分ける」仕様について、防衛的プログラミングでの実装を考えてみます。
ユーザーのタイプは以下の3つ存在しますが、キャンペーン画面を表示するのはそのうちのnomalDiscount
とlargeDiscount
の2タイプです。
- ineligible
- nomalDiscount
- largeDiscount
キャンペーン画面ではnomalDiscount
とlargeDiscount
で表示される文言が違います。
その部分で最初に私がレビューで見た実装はこんな感じでした。
let descriptionText: String {
switch userType {
case .ineligible: return ""
case .nomalDiscount: return "あなたは5%OFFで買えます!"
case .largeDiscount: return "あなたは10%OFFで買えます!"
}
}()
私はこの実装が凄い違和感でした。
何が違和感かというと、case .ineligible
は絶対通るはずのないロジックなのに、意味のあるコードとして存在してしまっていることです。
テストを書くことを考えると、意味のあるコードとして存在しているのでテスト観点に入ると思うのですが、このテストは書きたくないなと思いました。
ではこのコードを防衛的プログラミングの観点で見てみます。
そうすると、このUserType
は来るはずない!と信頼せず全ての値をハンドリングしており、なんだか防衛的プログラミングの意図に合っている気がしますね。
しかし私個人としてはスッキリしない…
そこで防衛的プログラミングの対比として出てくるのが契約的プログラミングです。
契約的プログラミング
契約的プログラミングとは
ソフトウェアモジュールの権利と責務を文書化(そして承諾)し、プログラムの正しさを保証するための簡潔かつパワフルな技法です。
ではここで言う正しいプログラムとは一体どういったものでしょうか?それは要求された以上のことも、以下のことも行わないと言うものです。
達人プログラマー 第2版 第4章
少し具体に寄った記述を見てみると…
ソフトウェアシステムの各関数やメソッドは、それぞれ「何らかの作業」を行います。
その「何らかの作業」の開始に先立って、世界の状態に対する「何らかの想定を置き、終了時でのその世界の状態に関する何らかの確約ができるはずです。
達人プログラマー 第2版 第4章
簡単に表すとこのような感じでしょうか。
達人プログラマーでは何らかの想定のことを事前条件、何らかの確約のことを事後条件と表現しています。以降この記事でも同様の表現とします。
実装を考えてみる
では前項の防衛的プログラミンで考えた箇所と同じところを、今度は契約的プログラミングで考えてみます。
キャンペーン画面に遷移する時の事前条件は何でしょう?
事前条件とは以下でなければなりません。
この機能を呼び出す前に満足させておかなければならない条件、つまりこの機能からの要求です。
事前条件に違反している場合には、この機能を呼び出してはいけません。
達人プログラマー 第2版 第4章
つまり今回の場合、キャンペーン画面を表示する事前条件で言うと「UserType
がnomalDiscount
またはlargeDiscount
であること」になります。
では事後条件はどうでしょう。
この機能が終了した後の世界の状態で、この機能が保証する内容です。
達人プログラマー 第2版 第4章
キャンペーン画面の説明文を表示する機能の事前条件が「UserType
がnomalDiscount
またはlargeDiscount
であること」だとすると、この事前条件が満たされた状態でこの機能が保証する内容は「nomalDiscount
の場合は5%off、largeDiscount
の場合は10%offと表記すること」になります。
事前条件が満たされていれば、UserType
がineligible
の状態を考慮する必要は無くなりますね。
実装はどうなるでしょうか。
UserType
がineligible
の状態を考慮する必要が無くなったとはいえ、enum
でUserType
を定義していた場合、ineligible
のハンドリングも必要になってきます。
さて、どうしたものでしょう。ここのハンドリングの疑問に関しては最後の攻撃的プログラミングの項で話していきたいと思います。
let descriptionText: String {
switch userType {
case .ineligible: // ここ、どうするのがいいの??
case .nomalDiscount: return "あなたは5%OFFで買えます!"
case .largeDiscount: return "あなたは10%OFFで買えます!"
}
}()
(小話) 防御的プログラミングと契約的プログラミンのポイント
契約的プログラミングでは、処理を受け付ける条件は厳格に、そして戻るときは可能な限り確約を少なくすることがポイントです。
そうすることによって、処理を担っているコードが肥大化することはありません。また、契約的プログラミングはテストを書くためにとても有効な考え方です。
防御的プログラミングと契約的プログラミングは対照の例として出されることが多いですが、では絶対どちらかに寄せなければならないのかと言ったらそうではありません。
防衛的プログラミングを実施している場合でも契約による設計は必要なのでしょうか?
ひと言で述べると、その答えは「イエス」です。
達人プログラマー 第2版 第4章
契約的プログラミングは、入力を一切信用せず全てのデータに対して検証する防衛的プログラミングと比べると、ずっと合理的で効率の良い手段に思います。
ですが実際の業務では想像もできないデータの破損やユーザーの行動が平気で起こるもので、それを無視することはできません。機能やサービスに応じて上手に使い分け時には混在させることが必要です。
攻撃的プログラミング
「Fail Fast」というキーワードの方が馴染みがあるかもしれません。
攻撃的プログラミングとは
「あり得ない」と思われる事象がコードの実行中に発生した場合、その時点でプログラムはもはや実行可能なものとはなっていないのです。
その時点以降に処理された内容は疑わしいものであるため、速やかに停止させてください。
達人プログラマー 第2版 第4章
こんな記述も存在します。
「防衛的プログラミングは時間の無駄だ。クラッシュさせろ!」という言葉で引き合いに出されます。
達人プログラマー 第2版 第4章
つまり、事前条件に反した場合のハンドリングとして、安全な防衛策を取るのではなく、それはサービスが機能していないことと同義と捉えクラッシュさせるべきという考えです。
クラッシュさせることで早期にバグに気が付き、サービスが意図しない状態で動き続けるのを阻止する意図があります。
実装を考えてみる
契約的プログラミングの続きを考えていきます。
let descriptionText: String {
switch userType {
case .ineligible: // ここ、どうするのがいいの??
case .nomalDiscount: return "あなたは5%OFFで買えます!"
case .largeDiscount: return "あなたは10%OFFで買えます!"
}
}()
契約的プログラミングで判断に迷ったineligible
のハンドリングですが、大きく2つの対応があるかと思います。
1つ目は、このUserType
のenum
をキャンペーン画面で扱うための専用のenum
に変換してあげることです。
変換したenum
をキャンペーン画面で受け取りそれを元に画面を制御します。
enum DiscountType {
case nomal
case large
}
2つ目はUserType
をそのまま受け取り、ineligible
はあり得ない事象としてクラッシュさせます。
これが攻撃的プログラミングの考え方です。
let descriptionText: String {
switch userType {
case .ineligible: fatalError()
case .nomalDiscount: return "あなたは5%OFFで買えます!"
case .largeDiscount: return "あなたは10%OFFで買えます!"
}
}()
少々乱暴な方法に感じますが、私もこちらの対応が正しいなと思います。
この画面は課金というサービスではとても重要な役割を担っている画面で、その画面の表記にバグを生むことは大きな問題に発展しかねません。
それは多少クラッシュというユーザーの体験を阻害する方法であっても、影響を受けるユーザーが少ないうちに早期に発見できた方が結果としてはユーザーの体験の向上とサービスの品質の向上につながると感じます。
最後に
みなさんの職場やみなさん自身はどんな考え方で実装していますか?
サービスや機能によっても変わってくるはずなので、いろんな方針が存在するかと思います。
この他にも契約による設計とテスト駆動開発についてや、DRY原則などいろんな要素があって設計を考えるって楽しいですね。
ぜひみなさんのお話も聞かせてください。