Swift Digest
SE-0026 | Swift Evolution

Abstract classes and methods

Proposal
SE-0026
Authors
David Scrève
Review Manager
Joe Groff
Status
Rejected

01 何が問題だったのか

フレームワークや再利用可能なライブラリを書いていると、「共通のふるまいや状態はスーパークラス側でまとめておき、特定のメソッドやプロパティだけは派生クラスごとに必ず実装してもらいたい」という形を取りたくなることがあります。C++ の純粋仮想関数や Java / C# の抽象クラスにあたる機能です。本提案は、こうした「部分的にしか実装されていないクラス」を Swift でも書けるように、abstract キーワードを導入しようとするものでした。

プロトコルでは stored property を持てない

「実装を強制したい」という要求だけを見れば、プロトコルとプロトコル拡張でも似たことはできます。プロトコルで要件を宣言し、共通のふるまいを拡張で提供すれば、適合する型にだけ差分を実装してもらえます。

しかし、抽象クラスで表現したいのは「共通のふるまいが stored property に依存している」ケースです。たとえば次のような、タイムアウト値を保持し、派生クラスごとに異なる URL を返させたい REST クライアントを考えます。

class RESTClient {

    var timeout = 3000

    var url: String {
        assert(false, "Must be overridden")
        return ""
    }

    func performNetworkCall() {
        let restURL = self.url
        print("Performing URL call to \(restURL) with timeout \(self.timeout)")
    }
}

class MyRestServiceClient: RESTClient {
    override var url: String {
        return "http://www.foo.com/client"
    }
}

timeout のような stored property を持ちながら、url だけは必ず派生側で実装してほしい、という構造です。プロトコルは stored property を持てないため、timeout のような「スーパー側で保持して初期化もしておきたい値」がある時点で、純粋にプロトコルだけで置き換えるのは難しくなります。

実行時 assert は型検査で守れない

上のコードでは、url を実装し忘れた派生クラスをインスタンス化して performNetworkCall() を呼ぶと、はじめて assert(false, ...) が走ります。コンパイル時には何も検知できないため、実装漏れはユーザーの手元でのクラッシュとして顕在化してしまいます。

delegate / datasource パターンでは継承が使えない

「派生クラスに実装させる」のをやめて、別オブジェクトに委譲する設計にする、という回避策も考えられます。しかしこの場合、スーパークラス側がクラス継承によって提供していた「共通の stored property やふるまい」を、委譲先に自然に持ち込むことはできません。継承と「未実装メンバの強制」を両立させる語彙が、Swift には無かったというのが出発点でした。

提案の狙い

本提案は、抽象クラスと抽象メンバを宣言する abstract キーワードを導入し、

  • 抽象クラスはインスタンス化できない
  • 抽象メンバを1つでも持つクラスは abstract と宣言しなければならない
  • 抽象クラスを継承するクラスは、すべての抽象メンバを実装するか、自身も abstract と宣言するか、のどちらかを選ぶ

というルールをコンパイラに検査させよう、というものでした。これによって「実装漏れはコンパイルエラー」になり、継承と未実装メンバの強制を、実行時 assert に頼らずに同時に表現できるようになるはずでした。

02 どのように解決されるのか

この提案は Rejected(却下) となりました。Swift には abstract キーワードは導入されず、「インスタンス化できないクラス」と「実装を派生クラスに強制するメンバ」を言語として直接表現する手段は、現在もありません。同等のことをしたい場合は、プロトコルとプロトコル拡張、あるいはジェネリクスと組み合わせて設計するのが一般的なアプローチです。

提案されていた内容(却下されたもの)

仮に採択されていれば、クラスとメンバに abstract キーワードが付けられるようになる予定でした。

abstract class RESTClient {
    var timeout = 3000

    abstract var url: String { get }

    func performNetworkCall() {
        let restURL = self.url
        print("Performing URL call to \(restURL) with timeout \(self.timeout)")
    }
}

class MyRestServiceClient: RESTClient {
    override var url: String {
        return "http://www.foo.com/client"
    }
}

提案されていたルールは次のとおりです。

  • abstract なクラスはインスタンス化できない
  • abstract なメソッド/プロパティは実装を持てない
  • abstract メンバを1つでも含むクラスは abstract と宣言しなければならない
  • 抽象クラスを継承したクラスは、未実装の抽象メンバが残っていれば自身も abstract として宣言する必要がある
  • これらの規則に違反すると、実行時ではなくコンパイル時にエラーとなる

プロパティの抽象化は override と同様、getter / setter / willSet / didSet のいずれにも適用できる想定でした。指定が無ければ getter と setter のみが抽象、willSet / didSet は空のデフォルト実装が与えられる、という整理です。また、抽象プロパティは型推論の元になる初期値を持てないため、型注釈が必須とされていました。

却下の背景

この提案は、Swift Evolution の初期レビューの結果として一度は「deferred(判断を後回し)」となり、その後、保留されたまま残っていた多数の提案を整理する一斉処理の中で最終的に Rejected として処理されたものです。独立して「採用しない」と結論付けられたというより、「Swift のクラスまわりの設計・継承モデルを大きく見直すような変更は、もっと広い視点で検討すべきであり、この提案単体のまま取り込むことはしない」という位置付けになりました。Swift は同じ領域の課題を、抽象クラスという形ではなく、プロトコル・プロトコル拡張・ジェネリクスを拡張する方向で解決してきており、その後も abstract 相当の機能は導入されていません。

現在のSwiftでの代替

抽象クラスで表現したい「共通のふるまいを持ちつつ、一部を実装者に任せる」構造は、現在の Swift では次のように書くのが定石です。

プロトコルで「実装を強制したい要件」を宣言し、プロトコル拡張で共通のふるまいを提供します。

protocol RESTClient {
    var url: String { get }
    var timeout: Int { get }
}

extension RESTClient {
    var timeout: Int { 3000 }

    func performNetworkCall() {
        print("Performing URL call to \(url) with timeout \(timeout)")
    }
}

struct MyRestServiceClient: RESTClient {
    var url: String { "http://www.foo.com/client" }
    // timeout は拡張のデフォルト実装がそのまま使われる
}

url をプロトコル要件として宣言しておけば、適合する型に url の実装が無いことはコンパイルエラーになります。これは、元の提案が狙っていた「実装漏れをコンパイル時に検出する」という性質と同じ効果です。

共通の stored property を保持したい場合は、プロトコル要件としてプロパティを宣言しつつ、適合する各型で let / var として持たせる、あるいはプロトコル拡張ではなくジェネリックな構造体・関数を挟んで実装を共有する、といった設計に切り替えます。「必ずクラス継承を使わなければ書けない」というケースは、Swift の型システムの範囲ではそれほど多くありません。

どうしてもクラスベースで「中間クラスがうっかりインスタンス化されないようにしたい」場合は、イニシャライザを private などに絞ってサブクラス専用にする、インスタンス化されたら実行時に fatalError を投げる、といった従来からのイディオムで代替することになります。これは提案が解消したかったまさにその状況であり、コンパイル時に検査できないという欠点は現在も残りますが、言語機能としての抽象クラスが入っていない以上、実務上はこちらのアプローチに頼ることになります。