will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

SwiftでExtensionのプロパティの黒魔術感をなくす(追記アリ)

SwiftでExtensionに追加するプロパティの黒魔術感が異常。

クラス全体には関係ないけど、特定のextension内に閉じ込めたいpropertyが欲しくなることがあると思います。

しかし、Swiftではextensionローカルなプロパティを持とうとするとobjc_getAssociatedObjectを使うため、
急に黒魔術感が異常なほどに感じられるソースが出来上がります。

associatedObjectは設計で回避できますが、より簡潔にextensionにpropertyにアクセスしたいという場合に有効です。

Associated Objectをつかう

// Model.swift
protocol Model {
    func load(id: String)
}

// Dog.swift
class Dog {
    var name: String
}

// Dog+Model.swift
fileprivate struct AssociatedKeys {
    static var disposeBagKey = "disposeBagKey"
}

extension Dog: Model {
    fileprivate(set) var disposeBag: DisposeBag {
        get {
            if let disposeBag = objc_getAssociatedObject(self, &AssociatedKeys.disposeBagKey) as? DisposeBag {
                return disposeBag
            }
            
            let initialValue = DisposeBag()
            objc_setAssociatedObject(self, &AssociatedKeys.disposeBagKey, initialValue, .OBJC_ASSOCIATION_RETAIN)
            return initialValue
        }
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.disposeBagKey, newValue, .OBJC_ASSOCIATION_RETAIN)
        }
    }

    func load(id: String) -> Dog {
        api.load(id: id)
            .subscribe(onNext: { [weak self] model in
                self?.name = model.name
            }).disposed(by: disposeBag)
    }
}

この実装がいけてるかは別としてこんな感じでdisposeBagfileprivateでアクセスすることができます。
disposeBagをDogに持たなくて済むので近いところにデータを置くことができてハッピーです。

黒魔術にフタをする

ExtensionPropertyというものを用意します。またキーはenumで定義出来るといいので、RawRepresentable かつ、RawValue == String でkeyStringでアクセスすればいい感じにキーを取得できるようにしています。

// MARK: - ExtensionPropertyKey
/// Extension内にセットするデータのキー
public protocol ExtensionPropertyKey: RawRepresentable {
    var keyString: String { get }
}

// MARK: - ExtensionPropertyKey
/// A default implementation for enum which is extend String
public extension ExtensionPropertyKey where Self.RawValue == String {
    var keyString: String {
        return self.rawValue
    }
}

// MARK: - ExtensionProperty

/// A strategy for manage extension local property.
public protocol ExtensionProperty: class {
    func getProperty<K: ExtensionPropertyKey, V>(key: K, defaultValue: V) -> V
    func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V)
}

// MARK: - ExtensionProperty
public extension ExtensionProperty {
    public func getProperty<K: ExtensionPropertyKey, V>(key: K, initialValue: V) -> V {
        var keyString = key.keyString
        if let variable = objc_getAssociatedObject(self, &keyString) as? V {
            return variable
        }

        setProperty(key: key, newValue: initialValue)
        return initialValue
    }

    public func setProperty<K: ExtensionPropertyKey, V>(key: K, newValue: V) {
        objc_setAssociatedObject(self, key.keyString, newValue, .OBJC_ASSOCIATION_RETAIN)
    }
}

実際につかってみる

// Dog+Model.swift
fileprivate enum ExtensionPropertyKey: String, ExtensionPropertyKey {
    case disposeBag
}

extension Dog: Model {
    fileprivate(set) var disposeBag: DisposeBag {
        get {
            return getProperty(key: ExtensionPropertyKey.disposeBag, initialValue: DisposeBag())
        }
        set {
            setProperty(key: ExtensionPropertyKey.disposeBag, newValue: newValue)
        }
    }
}

こんな感じで、若干黒魔術感にフタをすることができました。

キーかぶりの可能性を排除する

現状ですと、disposeBagが他の拡張で上書きされる可能性があります。また、型が違うキャストに失敗するのでバグを埋め込むことになります。そこで#file, #lineの出番です

fileprivate enum ExtensionPropertyKey: String, ExtensionPropertyKey {
    case disposeBagKey

        var keyString: String {
        return [#file.description, String(#line), self.rawValue].joined(separator: "::")
    }
}

こうすることで、ExtensionPropertyKeyが定義されているファイルのパスとソースの行番号が入るので絶対にかぶることはありません。

黒魔術感が復活した気がしますがこんな感じでextensionにpropertyを閉じ込められたので見通しも良くなりそうです。

2017/10/04 追記 getPropertyが必ずnilになってしまう

blog.matsuokah.jp