※結論が最初と変わり、それに伴ってタイトルも変えています。
開発初期の大きな抽象化には気をつけようという話
↓
開発初期の大きな抽象化には気をつけようという話...なのか?
これは下記の記事を基にした内容になっています。
https://swiftindepth.com/2019-03-24/a-case-of-premature-abstractions
私個人としては
「あーこういうことあるよなー」
と思ったので記録として残しました。
補足
本文の中で
「抽象化」「ジェネリクス」「ジェネリック」
などの言葉を使用していますが
「コードの共通化」
「コードの一般化」
といった意味で使用しています。
またタイトルにある「大きな抽象化」ですが
「影響範囲の広い抽象化」
「プロジェクト全体に影響を与えるような抽象化」
という意味で使用しています。
-----以下原文の意訳です-----
今回の内容について
ジェネリクスを用いた抽象化やprotocolは
非常に有用な存在として確固たる地位を築いています。
内部の実装を隠すことができ
具体的な詳細を見ることなしに内容を理解することを助けてくれます。
しかし
今回はあまりにも早い段階で
プロジェクト全体に影響を与えるような抽象化をしてしまった結果
間違った抽象化になってしまい
理解を助けるどころかむしろわかりづらくしてしまった事例を紹介します。
この失敗の原因は
その抽象化が必要かどうかを深く考える前に抽象化を行なってしまったことにあります。
想定する内容
ユーザのワークアウトを記録するアプリ
体の変化を追跡するために自撮りを保存することで
gifや動画を自動生成します。
こういう仕様からPhoto
モデルとWorkout
モデルがあり
Photo
を保存するためのPhotoStore
と
Workout
を保存するWorkStore
を定義しました。
Storeプロトコルの導入
さらに
PhotoStore
とWorkStore
の保存処理には共通点が多いため
Store
プロトコルを定義することにしました。
protocol Store {
associatedtype Element
var all: [Element] { get }
func fetchElements(offset: Int, amount: Int) -> [Element]
func findElement(id: String) -> Element?
func addElement(_ element: Element)
func updateElement(_ element: Element)
func removeElement(_ element: Element)
}
Element
にはそれぞれのモデルが当てはまるようにします。
class PhotoStore: Store {
typealias Element = Photo
}
class WorkoutStore: Store {
typealias Element = Workout
}
共通のページネーション機能の実装
さらにそれぞれのデータは一覧で表示する仕様があり
ページネーションの機能が必要でした。
ここでSwiftのプロトコルを活用して
extension
にデフォルト実装を行うようにしました。
protocol Store {
// ページネーション機能用のプロパティとメソッド
var elementsPerPage: Int { get }
var numberOfPages: Int { get }
func page(index: Int) -> [Element]
}
extension Store {
var elementsPerPage: Int { return 50 } // Default number of elements
var numberOfPages: Int {
guard elementsPerPage > 0 else { return 0 }
let pageCount = Float(all.count) / Float(elementsPerPage)
return Int(pageCount.rounded(.up))
}
func page(index: Int) -> [Element] {
return fetchElements(offset: index * elementsPerPage, amount: elementsPerPage)
}
}
これによってコード重複を避けることができ
PhotoStore
とWorkoutStore
で利用することができます。
let photoStore = PhotoStore()
photoStore.numberOfPages
photoStore.page(3)
let workoutStore = WorkoutStore()
workoutStore.page(4)
ジェネリックなUIViewController
さらにプロトコルを導入してUIViewController
を
ジェネリックにすることができます。
final class StoreViewController<AStore: Store> {
let store: AStore
}
※ associatedtype
を使用しているため
Store
プロトコルを直接型には利用できません。
ここで一覧を表示する用にUITableview
を利用するため
UITableviewController
を継承し
UITableviewDataSource
実装します。
この際にセルのタイトルに文字を設定したいと考えます。
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Element", for: indexPath)
cell.textLabel?.text = elements[indexPath.row].title
return cell
}
しかし
Element
はtitle
プロパティを持っていないため
コンパイルは通りません。
新しいプロトコルの追加
そこでtitle
プロパティを持つ
Presentable
という新しいプロトコルを導入することにしました。
protocol Presentable {
var title: String { get }
}
protocol Store {
associatedtype Element: Presentable
}
struct Photo: Presentable {
...
}
struct Workout: Presentable {
...
}
ここで今まで出てきたもののか関係を整理してみます。
2つのtableview
を表示するために
これらの関係を覚えておく必要があります。
新しい仕様の追加
ここで写真にお気に入り機能を追加する仕様が出てきました。
そのためにはElement
にfavorited
プロパティを追加したいと考えます。
Presentable
にfavorited
プロパティを追加しますか?
そうするとWorkout
にも不必要なfavorited
が追加され
さらにfavoritedはOptional
にするか
常にfalseにするかしなければならなりません。
コードの重複の避けるためにStoreViewController
は
ジェネリックのままにしておきたいですが
Workout
にはfavorited
は必要ありません。
そこでさらに新しいFavoritable
というプロトコルを導入しました。
protocol Favoritable {
var favorited: Bool { get set }
}
struct Photo: Presentable, Favoritable {
var favorited: Bool = false
}
そしてStoreViewController
に
Element
がFavoritable
の場合のメソッドを定義しました。
extension StoreViewController where Element: Favoritable {
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Element", for: indexPath)
let element = elements[indexPath.row].title
cell.textLabel?.text = store.all[indexPath.row].title
cell.favoritedIcon.hidden = !element.favorited
return cell
}
}
ここでcellForRowAt
メソッドに重複が発生しました。
しかしStoreViewController
を利用するために仕方がないという結論に達しました。
今度は仕様変更
これまでのところ
重複を避けPOP(プロトコル指向プログラミング)を実践してきました。
そこに写真機能の廃止という仕様変更が入りました。
ユーザは自撮り画像をアップロードをするのがあまり好ましくなかったようです。
これは問題ありません。
PhotoStore
と関連するコードを削除すれば良いだけです。
しかし
WorkStore
のためだけに大量のジェネリックなコードは残ってしまいました。
他のたくさんの機能も実装しなければいけないため
現時点でリファクタリングをするにはあまりにもコストがかかりすぎてしまいます。
結局その時点ではコードをそのままにしておくことにしました。
他のチームメンバーはWorkout
の一覧を表示するtableview
の実装を理解するために
Favoritable
Presentable
Store
Element
その他のextensionを理解しなればなりません。
さらなる仕様変更
さらにWorkout
のデータをオフラインでも参照できるようにする仕様が追加されました。
このためにCoreDataを使用することを決めましたが
将来的にRealmなどへ入れ替えが可能にするために
Store
プロトコルに適合するように実装をしようとしました。
しかしここでもう一度考えてみたところ
データベースを入れ替えるという
アプリに多大な影響を与えるような書き換えを行うことは
ほとんどないだろうという結論に達したため
抽象化されたStore
プロトコルを廃止し
より具体的に理解がしやすいCoreDataと密に結合したクラスを作成しました。
その結果PhotoStore
の変更も余儀なくされました。
何が起きたのか?
上記は
将来的にプロジェクトがどういう方向に向かっていくのかがわからない時点で
つまり仕様の追加や変更が入る可能性を無視して
現状わかっている範囲でコードをきれいにしていくために
全体に影響を与えるような抽象化を導入してしまったことにあると思われます。
抽象に抽象を重ねた結果
最終的に複雑さを増加させてしまい
他の開発者が理解するための高い障壁を築き
柔軟性を失わせてしまいました。
そして最終的には全体の構造の書き換えを行う結果になりました。
どうすればよかったのだろうか?
今回のケースに関して言えば
WorkoutViewController
とPhotosViewController
に分けておけば
Store
プロトコルで一つに分けることは避けられたかもしれません。
コードの重複は発生するが
プロジェクトがどういう方向に向かっていくのかが
わからなかったあの段階では
それも悪い判断ではなかった気がします。
写真にお気に入り機能を追加する際にも
Photo
にfavorited
プロパティを追加して
PhotosViewController
を使うだけでよかったかもしれない。
コードももっと簡単に理解できるものになっていたかもしれません。
WorkoutStore
を廃止する際にも
Store
プロトコルを廃止する手間は必要なかったでしょう。
もし重複を少なくしたかったならば
まずはよりリスクの少ないことから見ていくことで
もしかしたら他に再利用できる部分があったかもしれません。
共通のセルタイプのようなUIコンポーネントや
ViewController
の遷移を共通できた可能性もあります。
もしかしたらページネーション機能は単純なnextPage
メソッドだけで
十分だったかもしれません。
こうしたらよいのかもしれない
アーキテクチャをモデルしている最中は
影響範囲の小さい抽象化から始めるのが良いのではないでしょうか?
まずは何が機能して何が機能しないのかを知り
その機能の特徴を深く理解し
特徴がプロジェクトの中でどういう役割をしているか
どういう方向へ向かっていく可能性があるのかを理解すること。
その後仕様がもっと固まってきた時に
全体に影響を与えるような大きな抽象化を考えるのはどうでしょうか?
開始初期の間違った抽象化が疑われる典型パターン
「これは後で役に立つだろう」
心の声: おそらく使われないでしょう。
プロジェクトは変化していき必要なときには使えないものになっているでしょう「これは他の開発者に役に立ちそうだから抽象化しておこう」
心の声: おそらくあなたが作成したものにしか使われないでしょう。
だって他の開発者はそれについて何も知らないのですから。おおよそ「賢い」と感じる
過剰に片付けていると思われる(近藤まりえスタイルのように)
もちろん適切な抽象化の場合もある
開発初期時の抽象化がすべて間違った方向にいくなんてことは決してありません。
プロトコルから考えたいときもいくつもあると思います。
例えばまだAPIが未完成の場合。結合して開発をすることができません。
その際はプロトコルを利用してモックを用意するでしょう。
テストを書く時も同じようにプロトコルを宣言してモックを用意することもあるでしょう。
個人的な意見としては
型としてプロトコルを使って
影響範囲が少ない範囲でプロトコルを活用している場合
(変なデフォルト実装や制約ではなく、例えばデリゲートなど)
プロトコルから開始したら自滅するリスクは少ないかもしれません。
預言者は存在しない
プロジェクトがどういう方向にいく可能性があるかどうかを知るためには
ビジネススキルやユーザ動向の洞察など
多くのスキルが必要になります。
仮にあったとしてもプロジェクトが予期せぬ方向に向かっていく可能性もあります。
プロジェクトの方向を予言することはできませんが
私たちができることは
経験やユーザのニーズの観察を基にした予測をすることです。
そして私たちの長期的に持続可能な開発を達成するための
適切な抽象化を見つけるまで待ち続けることです。
抽象化されたコードは大変価値のあるものです。
避けるべきだとは決して言いません。
ただ、抽象化を行うときには慎重に考慮する必要があることを自覚して欲しいだけです。
メリットはコード量を減らし
今後導入させる型にも適用可能な点です。
デメリットはコードを理解して動かすまでに
理解するための負荷が増える可能性があることです。
今回のケースでは
重複が返って敏捷性を取得することがケースについて話しました。
-----意訳ここまで------
最後に
私個人としては筆者の言いたいことは理解でき
実際に早期に抽象化しずぎた結果
無理に抽象化に合わせてしまい
後々大きな変更が必要になってしまった経験があります。
コメントにも書いたのですが
自分が改めて「なんでこの記事に共感したのか」を考えた時に
「処理を共通化したい」という気持ちが強くありすぎて
ふわっと抽象化してしまった失敗経験があったからだったなと思いました。
それが私の場合も開発初期だったので
開発初期は気をつけようと思ったんだろうなと思います。
なので私の結論としては
抽象化を行う時には
「なぜその抽象化を行うのか」
「その抽象化の方法は適切なのか」
をきちんと検討してから導入しましょう。
(そうしないと痛い目を見ることがあります(実体験より))
ですかね
ご意見やご指摘などございましたら
ぜひコメントをいただけるとうれしいです🙇🏻♂️