Help us understand the problem. What is going on with this article?

開発初期の大きな抽象化には気をつけようという話...なのか?

※結論が最初と変わり、それに伴ってタイトルも変えています。

開発初期の大きな抽象化には気をつけようという話

開発初期の大きな抽象化には気をつけようという話...なのか?

これは下記の記事を基にした内容になっています。
https://swiftindepth.com/2019-03-24/a-case-of-premature-abstractions

私個人としては
「あーこういうことあるよなー」
と思ったので記録として残しました。

補足

本文の中で
「抽象化」「ジェネリクス」「ジェネリック」
などの言葉を使用していますが
「コードの共通化」
「コードの一般化」
といった意味で使用しています。

またタイトルにある「大きな抽象化」ですが
「影響範囲の広い抽象化」
「プロジェクト全体に影響を与えるような抽象化」
という意味で使用しています。

-----以下原文の意訳です-----

今回の内容について

ジェネリクスを用いた抽象化やprotocolは
非常に有用な存在として確固たる地位を築いています。

内部の実装を隠すことができ
具体的な詳細を見ることなしに内容を理解することを助けてくれます。

しかし
今回はあまりにも早い段階で
プロジェクト全体に影響を与えるような抽象化をしてしまった結果
間違った抽象化になってしまい
理解を助けるどころかむしろわかりづらくしてしまった事例を紹介します。

この失敗の原因は
その抽象化が必要かどうかを深く考える前に抽象化を行なってしまったことにあります。

想定する内容

ユーザのワークアウトを記録するアプリ

体の変化を追跡するために自撮りを保存することで
gifや動画を自動生成します。

こういう仕様からPhotoモデルとWorkoutモデルがあり
Photoを保存するためのPhotoStore
Workoutを保存するWorkStoreを定義しました。

Storeプロトコルの導入

さらに
PhotoStoreWorkStoreの保存処理には共通点が多いため
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)
    }
}

これによってコード重複を避けることができ
PhotoStoreWorkoutStoreで利用することができます。

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
}

しかし
Elementtitleプロパティを持っていないため
コンパイルは通りません。

新しいプロトコルの追加

そこでtitleプロパティを持つ
Presentableという新しいプロトコルを導入することにしました。

protocol Presentable {
    var title: String { get }
}

protocol Store {
    associatedtype Element: Presentable
}

struct Photo: Presentable {
    ...
}

struct Workout: Presentable {
    ...
}

ここで今まで出てきたもののか関係を整理してみます。

スクリーンショット 2019-05-25 10.59.37.png

2つのtableviewを表示するために
これらの関係を覚えておく必要があります。

新しい仕様の追加

ここで写真にお気に入り機能を追加する仕様が出てきました。

そのためにはElementfavoritedプロパティを追加したいと考えます。

Presentablefavoritedプロパティを追加しますか?
そうするとWorkoutにも不必要なfavoritedが追加され
さらにfavoritedはOptionalにするか
常にfalseにするかしなければならなりません。

コードの重複の避けるためにStoreViewController
ジェネリックのままにしておきたいですが
Workoutにはfavoritedは必要ありません。

そこでさらに新しいFavoritableというプロトコルを導入しました。

protocol Favoritable {
  var favorited: Bool { get set }
}

struct Photo: Presentable, Favoritable {
    var favorited: Bool = false
}

スクリーンショット 2019-05-25 11.28.42.png

そしてStoreViewController
ElementFavoritableの場合のメソッドを定義しました。

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の変更も余儀なくされました。

何が起きたのか?

上記は
将来的にプロジェクトがどういう方向に向かっていくのかがわからない時点で
つまり仕様の追加や変更が入る可能性を無視して
現状わかっている範囲でコードをきれいにしていくために
全体に影響を与えるような抽象化を導入してしまったことにあると思われます。

抽象に抽象を重ねた結果
最終的に複雑さを増加させてしまい
他の開発者が理解するための高い障壁を築き
柔軟性を失わせてしまいました。

そして最終的には全体の構造の書き換えを行う結果になりました。

どうすればよかったのだろうか?

今回のケースに関して言えば
WorkoutViewControllerPhotosViewControllerに分けておけば
Storeプロトコルで一つに分けることは避けられたかもしれません。

コードの重複は発生するが
プロジェクトがどういう方向に向かっていくのかが
わからなかったあの段階では
それも悪い判断ではなかった気がします。

写真にお気に入り機能を追加する際にも
Photofavoritedプロパティを追加して
PhotosViewControllerを使うだけでよかったかもしれない。
コードももっと簡単に理解できるものになっていたかもしれません。

WorkoutStoreを廃止する際にも
Storeプロトコルを廃止する手間は必要なかったでしょう。

もし重複を少なくしたかったならば
まずはよりリスクの少ないことから見ていくことで
もしかしたら他に再利用できる部分があったかもしれません。

共通のセルタイプのようなUIコンポーネントや
ViewControllerの遷移を共通できた可能性もあります。

もしかしたらページネーション機能は単純なnextPageメソッドだけで
十分だったかもしれません。

こうしたらよいのかもしれない

アーキテクチャをモデルしている最中は
影響範囲の小さい抽象化から始めるのが良いのではないでしょうか?

まずは何が機能して何が機能しないのかを知り
その機能の特徴を深く理解し
特徴がプロジェクトの中でどういう役割をしているか
どういう方向へ向かっていく可能性があるのかを理解すること。
その後仕様がもっと固まってきた時に
全体に影響を与えるような大きな抽象化を考えるのはどうでしょうか?

開始初期の間違った抽象化が疑われる典型パターン

  • 「これは後で役に立つだろう」
    心の声: おそらく使われないでしょう。
    プロジェクトは変化していき必要なときには使えないものになっているでしょう

  • 「これは他の開発者に役に立ちそうだから抽象化しておこう」
    心の声: おそらくあなたが作成したものにしか使われないでしょう。
    だって他の開発者はそれについて何も知らないのですから。

  • おおよそ「賢い」と感じる

  • 過剰に片付けていると思われる(近藤まりえスタイルのように)

もちろん適切な抽象化の場合もある

開発初期時の抽象化がすべて間違った方向にいくなんてことは決してありません。
プロトコルから考えたいときもいくつもあると思います。

例えばまだAPIが未完成の場合。結合して開発をすることができません。
その際はプロトコルを利用してモックを用意するでしょう。

テストを書く時も同じようにプロトコルを宣言してモックを用意することもあるでしょう。

個人的な意見としては
型としてプロトコルを使って
影響範囲が少ない範囲でプロトコルを活用している場合
(変なデフォルト実装や制約ではなく、例えばデリゲートなど)
プロトコルから開始したら自滅するリスクは少ないかもしれません。

預言者は存在しない

プロジェクトがどういう方向にいく可能性があるかどうかを知るためには
ビジネススキルやユーザ動向の洞察など
多くのスキルが必要になります。

仮にあったとしてもプロジェクトが予期せぬ方向に向かっていく可能性もあります。

プロジェクトの方向を予言することはできませんが
私たちができることは
経験やユーザのニーズの観察を基にした予測をすることです。
そして私たちの長期的に持続可能な開発を達成するための
適切な抽象化を見つけるまで待ち続けることです。

抽象化されたコードは大変価値のあるものです。
避けるべきだとは決して言いません。
ただ、抽象化を行うときには慎重に考慮する必要があることを自覚して欲しいだけです。

メリットはコード量を減らし
今後導入させる型にも適用可能な点です。

デメリットはコードを理解して動かすまでに
理解するための負荷が増える可能性があることです。

今回のケースでは
重複が返って敏捷性を取得することがケースについて話しました。

-----意訳ここまで------

最後に

私個人としては筆者の言いたいことは理解でき
実際に早期に抽象化しずぎた結果
無理に抽象化に合わせてしまい
後々大きな変更が必要になってしまった経験があります。

コメントにも書いたのですが

自分が改めて「なんでこの記事に共感したのか」を考えた時に
「処理を共通化したい」という気持ちが強くありすぎて
ふわっと抽象化してしまった失敗経験があったからだったなと思いました。

それが私の場合も開発初期だったので
開発初期は気をつけようと思ったんだろうなと思います。

なので私の結論としては

抽象化を行う時には
「なぜその抽象化を行うのか」
「その抽象化の方法は適切なのか」
をきちんと検討してから導入しましょう。
(そうしないと痛い目を見ることがあります(実体験より):point_up:)

ですかね:smiley:

ご意見やご指摘などございましたら
ぜひコメントをいただけるとうれしいです🙇🏻‍♂️

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away