モダンなSwiftのExtensionについて - Targeted Extensions

f:id:vasilyjp:20170419195827p:plain

VASILYのiOSエンジニアにこらすです。最近、Swift Evolutionに私の2つ目の提案がマージされました。

今回は、Swiftで型にExtensionを作る特殊な方法について説明します。 今回紹介する方法を使ってExtensionを作ると、名前空間が切り分けられ、コードの読み書きがしやすくなります。

ブログを書くに当たって、この Extension 実装方法を研究しましたが、この手法の正確な名前がわからなかったため、この記事では「Targeted Extensions」と呼ぶことにします。

Extensionについて

通常、 Extensionを書くとき、String なら下記のようになります。

extension String {
    var count: Int {
        return characters.count
    }
}

"hello".count // 5

Extensionを作ることで .characters.count.count だけで書くことができます。しかし、便利ではありますが、コードを読む時 .count プロパティは String に元からあるものなのか、Extensionで定義されたオリジナルのプロパティなのかがわかりません。 さらに、将来のSwiftのアップデートで同じ名前のプロパティが追加されてExtensionのプロパティと衝突してしまう可能性もあります。 これらの問題を回避するためには衝突を避けるような別のExtension実装方法が必要になります。

衝突を避けるために"some string".my_length のように『Extensionのメソッドにプレフィックスを付ける』という命名規則を採用することもできますが、言語の仕組みでこの命名規則を強制するような仕組みはありません。

"some string".ex_length ではなく "some string".ex.length と書けるなら、よりSwiftyな感じがしますが、どのようにすればよいのでしょうか?

それが今回のこの記事のテーマです!

最近では、 RxSwift, Kingfisher, ReactiveSwift などのOSSライブラリがこのExtensionの書き方を採用しています。

例を見よう: RxSwiftの場合

リアクティブプログラミングライブラリの RxSwift では rx というキーワードを使って、オリジナルのクラスに属するものと、 RxSwiftに属するものを明確に区別できるようになります。 また、Xcodeでのコード補完もキレイ動作します。

実際のコード例です。

myButton.rx
    .controlEvent(.touchUpInside)
    .subscribe(onNext: { _ in
        // ボタンをタップした時、ログに流れる
        print("Hello There!")
    })

Stringの場合は?

先ほどのStringのExtensionを下記のように書けると良いです。

"hello".ex.count    // 5

exがあることによって、名前空間が分けられます。そのため、他のサードパーティーライブラリがStringcountというプロパティを作ったとしてもプロパティ名が衝突することがありません。

さて、Targeted Extensionsはどのように動作しているのでしょうか?

ここから、このExtensionの動作について説明します。 5つのステップに分かれているので1つずつ説明しますが、それほど大きくないので、一旦全てのコードを貼っておきます。 下記のコードをXcodeのPlaygroundにコピー&ペーストすればそのまま動作します。

public protocol ExampleCompatible {
    associatedtype CompatibleType

    var ex: CompatibleType { get }
}

public final class Example<Base> {
    let base: Base
    public init(_ base: Base) {
        self.base = base
    }
}

public extension ExampleCompatible {
    public var ex: Example<Self> {
        return Example(self)
    }
}

extension String: ExampleCompatible { }

extension Example where Base == String {
   public var count: Int {
       return base.characters.count
   }
}

"hello".ex.count   // 5

ステップ1: exの定義

"hello".ex.count を見ると、 Stringex というプロパティを持っているはず。 どこで定義されているか確認しましょう。

public protocol ExampleCompatible {
    associatedtype CompatibleType

    var ex: CompatibleType { get }
}

上記のプロトコルを採用したらexというプロパティを持ってないといけません。 次はExampleCompatibleを採用しましょう!

ステップ2: ExampleCompatible

extension String: ExampleCompatible { }

StringExampleCompatible を採用していることがわかります。 しかし、ex の実装がありません。

ステップ3: ex の実装

public extension ExampleCompatible {
   public var ex: Example<Self> {
       return Example(self)
   }
}

まだ説明していませんが、Example<Self> 型の変数を返しています。 SelfExampleCompatible を採用している String になり、 self"hello" になるはずです。 色々な型で実現するために、ジェネリクスを使ってプロトコルを直接拡張します。そうすることでStringだけではなくNSStringIntでも同じ名前空間 を (ex) 使って様々な型を拡張することができます。

実際に様々な型に対応した場合、下記のようなコードになります。

"Yeah".ex.count
1024.ex.foo
["a", "bc", "def"].ex.hoge

このコードを見ると、foohogecountと同じ Example の名前空間に宣言されたものと分かります。

ステップ4: Example

public final class Example<Base> {
   let base: Base
   public init(_ base: Base) {
       self.base = base
   }
}

Example<Base>Base 型のプロパティを1つだけ持ったクラスです。 ここで Base は、ステップ3で説明したように String になります。

ステップ5: count の実装

extension Example where Base == String {
   public var count: Int {
       return base.characters.count
   }
}

Example<Base>BaseString の時だけ、動作するExtensionが宣言されています。 baseString なので、実際には "hello" が入っているはずです。

ここでやっと "hello" の文字数を返すことができます。

String 以外の実例

Targeted Extensions でどんな型でも拡張することができます。 例えばIntに偶数か奇数というプロパティを追加するには、Stringと同じように2つのステップで書けます。

まずIntExampleCompatibleに拡張します。

extension Int: ExampleCompatible { }

こうするとIntexというプロパティを持つので、Stringと同じようにExtensionを書けます。

extension Example where Base == Int {
    var isEven: Bool {
        return base % 2 == 0
    }
}

1.ex.isEven // false
2.ex.isEven // true

パフォーマンスについて

ex がアクセスされるたびに、新しい Example インスタンスが生成されるため、若干のパフォーマンスの差があります。

// 通常のExtension
extension String {
    var length: Int {
        return characters.count
    }
}

// 名前空間を使ったExtension
extension Example where Base == String {
    var length: Int {
        return base.characters.count
    }
}

第5世代 iPod touch で検証した結果 (5回テストした平均値)

実行回数 通常のExtension (sec.) Targeted Extensions (sec.)
100 0.02140 0.02239
1000 0.02475 0.02875
1000000 2.32037 4.12863

このパフォーマンステストの結果からわかるのは、Exampleインスタンスを 8848回生成して、やっと1フレーム(60FPSのとき16ms)の遅延が発生することになり、このパフォーマンスの遅延は無視できると言えます。

0.016 / ((4.12863 - 2.32037) / 1000000) ≒ 8848 回

こんな場面で導入しました

最近、私のライブラリでTargeted Extensionsを導入しました。良い実例になると思いますので、参考にしてみてください。

Nirma/Attributed

NSAttributedStringを型安全に書けるようにするライブラリです。(是非スターを付けてください)

let attributedText: NSAttributedString = "Steve".at.attributed {
    return $0.font(UIFont(name: "Chalkduster", size: 24.0)!)
             .foreground(color: .red)
             .underlineStyle(.styleSingle)
}

まとめ

この Extension の書き方によって、名前空間ができるため、メソッド名の衝突を避けることができます。

また、コードを読むときも、名前空間があることで、拡張されたメソッドなのか、元からあるメソッドなのかがわかりやすくなります。 Targeted Extensionsは少し複雑ですが、上記のようなメリットがあります。 VASILYでもSwiftの言語仕様に追加されない限りはTargeted Extensionsを採用していく予定です。

P.S なお、今回紹介した Targeted Extensions について、正しい名前をご存知の方は教えていただけると嬉しいです。

VASILYではモダンなSwiftコードを書きたいエンジニアを募集しています。 少しでも興味がある方は以下のリンク先をご覧ください。

www.wantedly.com

  • にこらす