with で iOS エンジニアをやっている t2low です。
iOS アプリ開発はまだ1年程度のひよっこですが、プログラミングの経験はそこそこ長くなってきた 40歳 Over のおっさんなので、いろいろと若い人たちに語りたくなっています。(もしかして老害に近づいている…?🤔)
最近 with の iOS チーム内で「良い実装とはどういうものか」みたいな話をしたこともあり、今回の記事を書きました。
チーム内で話をしたときは、うまく伝えられなかった部分もあったのですが、改めて整理してみると「自分が言いたかったことはこれだったかも」と納得できるものが書けた気がします。
少々長いですが、お読みいただければ幸いです!
問い
いきなりですが、問題です。
以下の仕様があります。
どのように実装しますか?
(以下で登場する実装例の言語は Swift ですが、その他言語の経験があれば読めると思います)
仕様
動物の鳴き声を知ることができるアプリ(CUI)です。
初回リリース時
- 動物は「犬」と「猫」を用意します。
- UI は以下のとおりです。
- 1行に「動物名: 鳴き声」を表示
- 実装されているすべての動物を改行して表示
犬: ワン
猫: ニャー
今後の拡張予定の仕様
- 今後も複数回のアップデートのリリース予定があります
- リリース毎に動物の種類を増やしていきます(優先度-高)
- 「動物名」「鳴き声」以外の情報を追加する可能性があります(優先度-低)
- 英語での動物名や鳴き声など
実装例
実装例を3つ挙げてみます。
その他にも思いつくかもしれませんが、この中から近いと思うものを選んでみてください。
実装例 1️⃣ - protocol 利用
// 注: protocol は Java 等での interface にあたるものです
protocol Animal {
var name: String { get }
var call: String { get }
}
// 注: struct は Java 等の class に類似のものだと思ってもらえれば、ここでは問題ないです
struct Dog: Animal {
let name: String = "犬"
let call: String = "ワン"
}
struct Cat: Animal {
let name: String = "猫"
let call: String = "ニャー"
}
let animals: [Animal] = [Dog(), Cat()]
animals.forEach { animal in print("\(animal.name): \(animal.call)") }
実装例 2️⃣ - enum 利用
// 注: CaseIterable は下で使用されている allCases を使用するためにつけられています
enum Animal: CaseIterable {
case dog
case cat
var name: String {
switch self {
case .dog: return "犬"
case .cat: return "猫"
}
}
var call: String {
switch self {
case .dog: return "ワン"
case .cat: return "ニャー"
}
}
}
// 注: allCases は、 Java の enum の values() にあたるものです
let animals = Animal.allCases
animals.forEach { animal in print("\(animal.name): \(animal.call)") }
実装例 3️⃣ - struct 利用
// 注: struct は Java 等の class に類似のものだと思ってもらえれば、ここでは問題ないです
struct Animal {
let name: String
let call: String
}
let animals = [
Animal(name: "犬", call: "ワン"),
Animal(name: "猫", call: "ニャー"),
]
animals.forEach { animal in print("\(animal.name): \(animal.call)") }
良い実装とは
どの実装方法を良いと感じたでしょうか?
一言で「良い実装」と言っても、色々な視点があり「一概にどれが良いとは言えない」と考えるかもしれません。
しかし、実際に実装することになれば、何か一つの方法を選んで実装しますよね?
それはどれでしょうか?
それを選んだ理由を言語化できるでしょうか?
良い実装の判断基準として考えられることはいくつもありますが、最優先されるべきは「不具合がないこと」でしょう。
正しく動いているコードに勝るものはありません。
その点では、上に挙げた3つの実装例には現時点で不具合はありません。
その評価基準だけでは、評価は「どれも一緒」になってしまいます。
良い実装を考える上では「不具合がないこと」以外の評価基準が必要です。
「可読性」や「拡張性」「保守性」「テスト容易性」などなど、いくつも思い浮かぶでしょう。
人によって何を重視するかは変わってきますが、すべて「不具合を出さないため」のものと言えます。
不具合を出しにくい実装とは
具体的な「〜性」を基準に考えるのではなく、大きな視点で「不具合を出しにくい実装」とはどんなものかを考えてみましょう。
世の中には数えきれないほどプログラムがありますが、全く不具合のないものはほとんどないでしょう。
些細な表示不具合かもしれませんし、ユーザーのデータを壊してしまうような致命的な不具合かもしれません。大抵のプログラムには何かしら不具合が紛れ込んでいます。
逆に全く不具合のないプログラムは存在するでしょうか?
どんなプログラムでも良いなら、不具合のないプログラムはありますよね。
たとえば「Hello, world.と表示するプログラム」は、私でも不具合なく作ることができます。
小さなプログラムほど不具合を出しにくい傾向があるということは間違いないと思います。
仕様がシンプルな方が、仕様が簡単な方が、不具合を出しにくいでしょう。
実装についても同様で、いかにシンプルに実装するかが不具合の出しにくさに繋がってくると考えています。
シンプルな実装とは
シンプルな実装とは、コードの短さを競うものではありません。
余計なことをせず、仕様を満たす最低限の実装を目指すものだと考えます。
YAGNI の原則 でも以下のように書かれていますね。
バグを減らすために最も良い方法も、あまりコードを書かないことである。
コードを書かない方が良いとしても、仕様を満たさなくなるまで削ってしまっては不具合になってしまいます。
もちろんバランスは大切です。
では、3つ挙げた実装例の中で最もシンプルな実装はどれでしょうか?
1️⃣ は犬や猫が動物であることを素直にコードで表現できているように感じられます。
2️⃣ は「すべて表示する」という仕様を enum を使ってうまく表せています。
3️⃣ は見てわかる通りコードが一番短いですね。
コード量だけで言えば 3️⃣ ですが、1️⃣ や 2️⃣ についても、仕様をコード上で表現するために別の実装方法をとったまでで、余計なことをしているという感じはしません。
シンプルな実装を選ぶという点で、これらに差はないでしょうか?
私が良い実装として選ぶものは
私が良い実装として選ぶのは 3️⃣ です。
人によっては異なる意見を持つ方もいるかもしれません。あくまで私個人の意見としてお読みください。
3️⃣ を選ぶ理由は「コードが短いから」ではありません。
結果としてコードが短くなっていますが、他のコードと比べて 3️⃣ だけコード上に登場する概念が少ないからです。
コード上に登場する概念とは
1️⃣ 2️⃣ では「犬」と「猫」が具体的なコードとして書かれています。
// 1️⃣ のコード
struct Dog: Animal { ... }
struct Cat: Animal { ... }
// 2️⃣ のコード
enum Animal: CaseIterable {
case dog
case cat
...
}
一方、 3️⃣ では「犬」「猫」はデータとしてのみ存在し、コード上で具体的に示すものはありません。
// 3️⃣ のコード
let animals = [
Animal(name: "犬", call: "ワン"),
Animal(name: "猫", call: "ニャー"),
]
1️⃣ 2️⃣ は Dog
/ dog
、 Cat
/ cat
といった識別子を持ちます。「犬」「猫」をコードから識別できるようになっています。「犬」「猫」の概念があるコードです。
3️⃣ はデータとして 「犬」「猫」は登場しますが、コード上では区別されていません。「犬」「猫」の概念がないコードです。
概念のシンプルさ
プログラムを小さくする実装するには、取り扱う概念の数を抑えることが大切です。
先述したとおりですが、プログラムを小さく抑えられれば、バグの発生件数も抑えられるはずです。
識別子の定義にかかる命名コスト
新たな概念を取り入れると、識別子を定義することになります。
そこには多くの命名コストがかかっています。
英語への翻訳
プログラミングは基本的に英語で書く方が多いと思いますので、馴染みのない言葉は調べなければなりません。
私なんかでは「トナカイを追加して」と言われても、調べなければ英単語がわかりません…。(reindeer だそうです。知ってました?)
識別子の重複が許されない
enum の要素名はもちろんですが、(基本的に)クラス名、構造体名も同じ名前をつけることができません。
仮に「犬の鳴き声としてバウとキャンも追加して」と言われたら、どのような識別子を定義するのが良いでしょうか。
格好悪いですが dog2
、 dog3
とする(※)か、識別子は増やさずに構造自体を変更して鳴き声を配列にするか…。
鳴き声のイメージから勝手に largeDog
、 smallDog
としても良いかもしれません。
(※: 対応例として挙げましたが、 dog2
dog3
のような命名をせざるを得ないなら別の実装方法を考えた方が良いでしょう)
データに紐づく識別子
今回の仕様では発生しづらいですが、データに紐づいた識別子を定義した場合、データの変更があった場合にデータと識別子に差が生じることがあります。
「犬」の動物名を「『嬉しい気持ちのときの犬』に変えて」と言われたらどうしましょうか。
dog
のままにしておくのか、 happyDog
に変更すべきか…。
今回の件に限らず、アプリの中の呼び名が変わったけど、コード上の識別子は古い名前のまま…ということは、どこにでもあるのではないでしょうか…。
コード1行1行に責任を
これを読んでいる皆さんも、上級者(あるいは書籍など)から「理由を説明できないコードを書くな」ということを言われた(読んだ)ことがあるのではないかと思います。
私は「プログラムを小さく抑えるため」という理由を重視し 3️⃣ を選択しましたが、1️⃣ 2️⃣ についてもきちんと選択理由を説明できるのであれば、良い実装と言えるでしょう。
「犬」「猫」の概念を取り入れること・識別子を定義することの理由を説明できるか考えてみます。
1️⃣ を選択する理由はあるか
1️⃣ は、継承や interface、 protocol のサンプルとしてよく登場するコードによく似ていて、見慣れた方も多いと思います。
しかし、最近は継承を駆使したプログラミングは推奨されないことが多く、 1️⃣ を選択した方は少ないのではないかと思います。
「仕様で犬・猫に言及されていたから」というのは動機になるかもしれませんが、採用理由としては弱いと思います。
Dog
、 Cat
を定義することで得られるメリットを説明できません。
protocol を利用することで「犬・猫が動物であることがわかること」は動作上も仕様理解の上でもメリットはないでしょう。
今回のような異なる定数を返却するためだけに、継承関係を用いるのはオススメできません(個人的には極力やらないで欲しい)。
継承関係はふるまいを変えるときに扱いたいものです。
ちなみに、異なるふるまいも、異なる定数も両方必要な場合は、ふるまい部分にだけ継承関係を適用するのが良いでしょう。よほどのことがない限り、全体を protocol にする必要はありません。
protocol Action { ... }
struct Animal {
let name: String
let call: String
let action: Action
}
2️⃣ を選択する理由はあるか
Swift は enum の機能が強力で、とても柔軟に使うことができます。
また、enum の使用箇所では網羅性のメリットを得ることができます。
では、2️⃣ の選択理由として「網羅性のメリットを得られるから」は妥当でしょうか。
個人的には、今回の仕様でも網羅性のメリットは得られるが、極めて限定的だと思いました。
まず enum の使用箇所は動物の情報(名前、鳴き声以外)が追加されない限り増えることがありません。
外側から利用されるということを想定していない enum の特殊な使い方をしています。
動物の種類(犬、猫以外)が追加されたときに、網羅的に名前や鳴き声を実装できますが、3️⃣ の場合でも追加自体を忘れなければ差はないでしょう。
(2️⃣ でも case の追加漏れは 3️⃣ のデータ追加漏れと同程度起こり得るものと思います)
Swift の enum は強力なので、つい使ってしまいがちです(個人の印象です)。
しかし、網羅性の真価を発揮できない場合もあります。
選択理由が「enum を使いたいから」になっていないか、利用する前に一度考え直すくらいがちょうど良いのではないかと思います(それくらい enum が強力で魅力的に見えるということです…)。
拡張予定の仕様まで記載した理由
拡張予定の仕様まで記載したのは、初期実装時においても、将来の拡張性を考慮してほしかったからです。
1️⃣ や 2️⃣ の実装方法を拡張仕様にも適用していく場合、次々と動物名を定義しなければいけなくなります。3️⃣ の実装と比べるとコストが高いのが感じられると思います。
追加実装の都度、設計の見直しを行えるのならば最初にどのような設計をしても構わないとも言えるのですが、いつでも見直しができるとは限りません。過去の実装に引きずられたまま拡張を余儀なくされ、技術的負債が広がっていく経験をしたことがある方もいるのではないでしょうか?
初期実装者がプロジェクトから離れた場合、後任の力量によっては設計を変更すべきだと気付けない可能性もあります。
拡張性を考えることは、将来のことを考えることです。
それは YAGNI の原則に反するのではないかと考えるかもしれません。
ですが、 YAGNI の原則では「考えること」は特に否定されていません。
機能は実際に必要となるまでは追加しないのがよいとする
考えることは必要です。コードにしないことが重要です。
必要になった時点で必要な実装をするということは、思いついたままに実装し、設計を将来に持ち越すことではありません。
将来のためのコードは書かないが、拡張性は確保するというのは難しそうですね。
予期しない変更に対しては、設計を単純にすることが備えとなる。
拡張性についても、シンプルさが鍵となります。
将来のためにコードを書くのではなく、将来のためにコードを書かないでおくのが良いでしょう。
継承や enum の利用を否定するものではありません
今回の仕様に限って言えば、 3️⃣ が適しているのではないか、と言いたいのであって、例として挙げた継承や enum の利用を否定するものではありません。
ポリモーフィズムを実現するには継承を用いることできれいに書けることがあると思います。
Swift の Result
型は enum で、多くの箇所で便利に活用されています。
上でも少し触れましたが、 Swift の enum は強力で魅力的です。
私はそこにかつての「継承」と似た雰囲気を感じています。(杞憂かもしれません)
きちんと目的を意識して、継承も enum も乱用せずに適材適所で使っていきたいものです。
まとめ
長々と書いてきました。お読みいただきありがとうございます。
この記事で言いたかったことは以下になります。
- 良い実装とは、不具合の出にくい実装
- プログラムの規模が小さいほど、不具合は出にくい傾向がある
- プログラムを小さく保つために、極力不要な概念は取り入れないようにしよう
- 言語の強力な機能は目的を意識して適材適所で使っていこう
賛同意見、反対意見、ぜひともお聞かせいただければと思っています。
PR
with ではエンジニアの採用を行っています。
興味を持たれた方は、ぜひお気軽にお問い合わせください。
決まったフローはないのですが、カジュアル面談も可能です 🙆♂️
こちらもお気軽にご相談ください♪