今度のSwift 3のリリースでは、大規模な改善が行われ、Objective-Cの遺産による足かせの大部分が取り払われます。しかしながら、これら改善には未だに”文字による型付け (Stringly typed)”によるAPIに依存しているものもあり、アプリ開発中にわれわれをつまづかせる恐れがあります。
このtry! Swiftの講演では、どうやってこのようなAPIを使うのを避けるか、コードを読みやすく、安全で、計画的に、そしてSwiftyなものに置き換えていくのかを見てみます。
文字による型付け(Stringly typed)によるAPIとは? (0:18)
これは 強い型付け (strongly typed) のもじりで、プログラマにやさしく、リファクタリングしやすいオプションが用意されているにもかかわらず、不必要にStringに依存した実装を説明するときに使われます。
// Stringly typed
let image = UIImage(named: "PalletTown")
// Strongly typed
let tableView = UITableView(style: .Plain)
上のふたつの例のうち、上は文字による型付け(Stringly typed)です。UIImage
のイニシャライザを使うとき、引数にはStringを使い、そのStringはリソースファイルなどに対応しています。一方、UITableView
のイニシャライザの引数にはEnumを使います。このEnumによってPlain StyleかGourp Slyleでのみ初期化できます。
欠点 (0:51)
打ち間違え
手入力したり、プリコンパイラがないと、よく打ち間違えますよね。例えば私はいつも”notification”を打ち間違えます。
let notification = "userAlertNotificaton"
副作用
UIImageのイニシャライザで”Charamander”を間違えました。これが副作用を引き起こし、nilが返ってきます。
let image = UIImage(named: "Charmandr")
衝突
仮にUserDefaultがあって、アプリ内のパートAは isUserLoggedIn
をfalse、パートBは isUserLoggedIn
をtrueにしたとします。A、Bはお互いのことを知りません。しかし同時に違う値を同じキーで保存しました。このとき、予期していなかった副作用が起こりますよね。
let keyA = "isUserLoggedIn"
let keyB = "isUserLoggedIn"
keyA == keyB
ランタイムクラッシュ
iOS5以前のTableViewでは必要ありませんでしたが、iOS 5からはUITableViewCell
を表示したいとき、セルにReuse Iedentifierを登録するようになりましたね。
UITableView
の関数を見てみましょう。.dequeueReusableCellWithIdenifier
という関数があります。
ここで、”CellIdentifier”を打ち間違えたので、ランタイムクラッシュが起きました。このようなつまらないクラッシュは避けたいです。
tableView.registerClass(UITableViewCell.self,
forCellReuseIdentifier: "CellIdentifer")
...
let cell: UITableViewCell = tableView
.dequeueReusableCellWithIdentifier("CelIdentifer")
Unicodeアーティファクト
もしリッチテキストエディタやiMessageのような、スタイル付きのテキストから文字列をコピーしたら、ソースコードには初めは何も無いように見えるものが加えられます。例えば、Xcodeにおける絵文字はそこには何も無いように表示されると思います。
これの良いところは、Objective-Cにのみ影響を与えることです。Swiftのプリコンパイラは「ねえ、不正な文字が入ってるから直してよ」って教えてくれます。
無秩序
文字による型付けが無秩序だと思えるでしょう。ここでタイトルケース vs. キャメルケースを用意しました。もしあなたが私のように強迫性障害 (OCD)なら、この戦いは深夜まで続きます。 訳者注)強迫性障害 (OCD)…ここでは、細かいことが気になるというニュアンスを伝えるために用いられている。
- タイトルケース
"IsUserLoggedIn"
- キャメルケース
"isUserLoggedIn"
- プレフィックス プレフィックスを使うことがあります。これはいいですよ、なぜならキーに名前空間が使えますからね。複数のプレフィックスで衝突もおこりません。一番下の例では、Bundle Identifeirまで使いました。外部フレームワークと衝突する可能性があるときはこんなことしますよね。
"isUserLoggedIn"
"Account.isUserLoggedIn"
"com.andyyhope.Account.isUserLoggedIn"
- 命名 3つの方法でキーをつけてみました。
"isUserBlocked"
"userBlocked"
"hasUserBeenBlocked"
時制を混ぜました。このやり方はどこでもされていて、UIImageやUIKit、Foundationが特に大きく影響を与えています。
UserDefaults (4:17)
簡単に復習しましょう。UserDefaultsってなんでしたっけ?小さな永続化データを保存するのに使いますね。
私は設定の保存に使うのがいいと思っています。小さなオブジェクトの保存に使うこともありますよ。Core Dataに入れたくないですからね。Core Dataの代わりとしてUserDefaultsを使う人たちもいますよね。だからUserDefaultsのことを「ダイエットCoreData」って呼ぶんですよ。パフォーマンス関係なしにCore Dataの雰囲気をすべて持ってますが、残念ながら、文字による型付けがなされています。
はじめに、Swift 3の変更を簡単に振り返りましょう。
インスタンス
- 変更前
NSUserDefaults.standardUserDefaults()
- 変更後
UserDefaults.standard
NSUserDefaults
はUserDefaults
に置き換わりました。さらに、Swift APIガイドラインに従って、インスタンスはstandard
です。関数はどこにもありません。ただの計算済みプロパティです。
Set API
- 変更前
.setBool(true, forKey: "isUserLoggedIn") .setInt(1, forKey: "pokeballCount") .setObject(pikachu, forKey: "pikachu")
- 変更後
.set(true, forKey: "isUserLoggedIn") .set(1, forKey: "pokeballCount") .set(pikachu, forKey: "pikachu")
setBool
、setInt
、setObject
は必要ありません。set
です。Swiftにおいてこれは関数のオーバーロードと呼ばれます。同じ関数名、パラメータ名を持っていますが、型が違います。コンパイラが引数パラメータに基づいて、どのAPIを意図して呼んでいるのか推測してくれます。(上の例では)僕はBoolを渡しました。だから、setBool
が呼ばれるのです。
Get API
- 変更前
.boolForKey("isUserLoggedIn") .integerForKey("pokeballCount") .objectForKey("pikachu")
- 変更後
.bool(forKey: "isUserLoggedIn") .integer(forKey: "pokeballCount") .object(forKey: "pikachu")
getのAPIはほとんど同じです。Swift APIガイドラインに従うようになった点を除いてはね。第一引数名を関数名から出して、引数名にしました。
Synchronize
Synchronizeは非推奨になりました。
- 変更前
NSUserDefaults.standardUserDefaults().synchronize()
- 変更後
// deprecated
UserDefaultsの修正 (6:49)
UserDefaultsは文字による型付けがされたAPIだと言いましたね。Swift3ではこんな感じです。さあ、直していきましょう。
// Setter
UserDefaults.standard.set(true, forKey: "isUserLoggedIn")
// Getter
UserDefaults.standard.bool(forKey: "isUserLoggedIn")
```swift
最初に、Stringは一回以上書くなら、定数にしましょう。
```swift
UserDefaults.standard.set(true, forKey: "isUserLoggedIn")
UserDefaults.standard.bool(forKey: "isUserLoggedIn")
...
let isUserLoggedInKey = "isUserLoggedIn"
UserDefaults.standard.set(true, forKey: isUserLoggedInKey)
UserDefaults.standard.bool(forKey: isUserLoggedInKey)
これが打ち間違いを防いでくれます。またコードが簡単で読みやすく見えます。
Objective-Cではおなじみのパターンとして、単一化することがありましたね。
struct Constants {
struct Keys {
// Account
static let isUserLoggedIn = "isUserLoggedIn"
// Onboarding
...
}
}
Constants
というStructがあって、その中にKeys
というネストしたStructがあります。視野を広げてみると、どんな定数も一貫性を保つのに役立っています。
UserDefaultsのAPIに持ち込むと、こんな感じになります。
UserDefaults.standard
.set(true, forKey: Constants.Keys.isUserLoggedIn)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.isUserLoggedIn)
Account
というさらにネストしたStructを使いましょう。
struct Constants {
struct Keys {
struct Account {
static let isUserLoggedIn = "isUserLoggedIn"
}
}
}
static let
にしているのは、キーとして使う度に毎回途中のStructを初期化したくないからです。こんな感じになります。
UserDefaults.standard
.set(true, forKey: Constants.Keys.Account.isUserLoggedIn)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.Account.isUserLoggedIn)
ネストしたStructであるAcountを使ったからキーが長くなってしまいました。
Constants.swift
struct Account {
static let isUserLoggedIn = "isUserLoggedIn"
}
繰り返しになりますが、これはエラーになりやすいですね。isUserLoggedInに間違ってもう一つ”N”をつけちゃったり、これを本番コードにいれちゃったら、自分が嫌になるでしょうね。Enumに変えた方がいいでしょう。
Constants.swift
enum Account : String {
case isUserLoggedIn
}
なんでenumかって?Account
というEnumはStringRawRepresentableに適合しているんです。
static let
の代わりにcaseを使います。Enumを使って良いことは、StringRawRepresentableに適合しているから、値を定義しなくても、その値はcaseと同じになることです。これを使うとこんな風になります。
struct Constants {
struct Keys {
enum Account : String {
case isUserLoggedIn
}
}
}
ちょっとよくなりましたね。
// Set
UserDefaults.standard
.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
まとめますよ。
// Strings
let key = "isUserLoggedIn"
// Constant & Grouped
let key = Constants.isUserLoggedIn
// Context
let key = Constants.Keys.Account.isUserLoggedIn
// Safety
let key = Constants.Keys.Account.isUserLoggedIn.rawValue
最初はStringからはじめました。その次は定数に変換して、グループにまとめました。Stringのパスと定数のパスに文脈をつけました、isUserLoggedInの定数キーについて言っていますよ。そしてenumに変えることで更にちょっとだけ安全にしました。
UserDefaults.standard
.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
UserDefaultsの拡張 (10:09)
このコードから、
// Set
UserDefaults.standard
.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
このコードに変更しましょう。
// Set
UserDefaults.standard
.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
UserDefaults.standard.set(true, forKey: .isUserLoggedIn)
// Get
UserDefaults.standard
.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)
UserDefaults.standard.bool(forKey: .isUserLoggedIn)
Swiftを誰が書いているか知っていて、この問題を解決してくれと頼むんだったら、その人が全てプロトコルで解決すると答えることもわかっているでしょう。
プロトコル (11:03)
こんなプロトコルはどうでしょう?BoolDefaultSettable
と名付けました。
protocol BoolDefaultSettable {
associatedtype BoolKey : RawRepresentable
}
ご覧のように、BoolKey
という関連型
を取り、RawRepresentable
に適合しています。
この説明をする前に、Crustyさんを知ることになった、WWDC 2015のことを思い出しましょう。
Crustyさんは知ってると思いますが、彼の第三法則は知らないと思います。「どんなプロトコルにも等しく対応するプロトコル拡張がある」これに従って、プロトコル拡張を追加します。
protocol BoolDefaultSettable {
associatedtype BoolKey : RawRepresentable
}
extension BoolDefaultSettable where BoolKey.RawValue == String {
}
ご覧のように、where
句がありますね。これに今から…
extension BoolDefaultSettable where BoolKey.RawValue == String {
// Setter
func set(_ value: Bool, forKey key: BoolKey) {
let key = key.rawValue
UserDefaults.standard.set(value, forKey: key)
}
}
UserDefaultと同じAPIになりましたね。
extension BoolDefaultSettable where BoolKey.RawValue == String {
// Getter
func bool(forKey key: BoolKey) -> Bool {
let key = key.rawValue
return UserDefaults.standard.bool(forKey: key)
}
}
まとめるとこんな感じです。
protocol BoolDefaultSettable {
associatedtype BoolKey : RawRepresentable
}
extension BoolDefaultSettable where BoolKey.RawValue == String {
func set(_ value: Bool, forKey key: BoolKey) {
let key = key.rawValue
UserDefaults.standard.set(value, forKey: key)
}
func bool(forKey key: BoolKey) -> Bool {
let key = key.rawValue
return UserDefaults.standard.bool(forKey: key)
}
}
BoolKeyはありますが、残りのIntegerやDoubleやFloatやURLにも同じことできますね。
protocol IntegerDefaultSettable { ... }
protocol DoubleDefaultSettable { ... }
protocol FloatDefaultSettable { ... }
protocol ObjectDefaultSettable { ... }
protocol URLDefaultSettable { ... }
さあ、UserDefaultsを拡張しましょう。
extension UserDefaults : BoolDefaultSettable {
enum BoolKey : String {
case isUserLoggedIn
}
}
UserDefaultsは今やBoolDefaultSettable
に適合するよう拡張されています。そのために、関連型を含めなくてはいけません。caseはBoolKey
で、Enumです。StringRawRepresentableに適合しています。最初のcaseはisUserLoggedIn
です。こんな風になります。
extension UserDefaults : BoolDefaultSettable {
enum BoolKey : String {
case isUserLoggedIn
}
}
...
UserDefaults.standard.set(true, forKey: .isUserLoggedIn)
UserDefaults.standard.bool(forKey: .isUserLoggedIn)
よくなりましたよね?プロトコルを作ったからといって、UserDefaultsに限定しているわけではありません。文脈を持たせるために、他のオブジェクトも拡張できます。
extension Account : BoolDefaultSettable {
enum BoolKey : String {
case isUserLoggedIn
}
}
...
Account.set(true, forKey: .isUserLoggedIn)
Account.bool(forKey: .isUserLoggedIn)
Account
に対してです。しかし、これには衝突の問題があるのはわかりますか?
Account.BoolKey.isUserLoggedIn.rawValue
// key: "isUserLoggedIn"
UserDefaults.BoolKey.isUserLoggedIn.rawValue
// key: "isUserLoggedIn"
Account.BoolKey.isUserLoggedIn
はUserDefaults.BoolKey.isUserLoggedIn
とまったく同じです。なので、他のプロトコルを作ります。
protocol KeyNamespaceable {
func namespaced<T: RawRepresentable>(_ key: T) -> String
}
簡単な関数がひとつです。RawRepresentableに適合したジェネリクスをとり、Stringを返します。再び、Crustyの第三法則に従い、拡張します。
protocol KeyNamespaceable {
func namespaced<T: RawRepresentable>(_ key: T) -> String
}
extension KeyNamespaceable {
func namespaced<T: RawRepresentable>(_ key: T) -> String {
return "\(Self.self).\(key.rawValue)"
}
}
文字補間を利用した単にStringを返す関数です。selfの文字列版とrawValueを組み合わせ、ドットで区切ったものです。この場合、 UserDefaults.isUserLoggedIn
となります。
protocol KeyNamespaceable {
func namespaced<T: RawRepresentable>(_ key: T) -> String
}
extension KeyNamespaceable {
func namespaced<T: RawRepresentable>(_ key: T) -> String {
return "\(Self.self).\(key.rawValue)"
}
}
// key: "UserDefaults.isUserLoggedIn"
BoolDefaultSettable
に戻って、 namespaced
を持ったBoolのキーにしましょう。
protocol BoolDefaultSettable : KeyNamespaceable {
associatedtype BoolKey : RawRepresentable
}
セッターとゲッターに戻りましょう。 BoolDefaultSettable
が namespaced
Boolのキーを持つようになったので、 let key = namespaced(key)
とするだけです。
extension BoolDefaultSettable where BoolKey.RawValue == String {
func set(_ value: Bool, forKey key: BoolKey) {
let key = namespaced(key)
UserDefaults.standard.set(value, forKey: key)
}
func bool(forKey key: BoolKey) -> Bool {
let key = namespaced(key)
return UserDefaults.standard.bool(forKey: key)
}
}
これで衝突はなくなりました。
UserDefaults.set(true, forKey: .isUserLoggedIn)
// key: "UserDefaults.isUserLoggedIn"
Account.set(true, forKey: .isUserLoggedIn)
// key: "Account.isUserLoggedIn"
Swiftのかっこいいところは、クラスやオブジェクトを拡張するときに、その拡張は同じクラスやSwiftファイルにある必要はないことです。Constants.swiftでやったパターンを振り返ってみると、Constants.swiftファイルから拡張を引っ張り出せましたよね、 Constants
構造体の外に。
extension Account : BoolDefaultSettable { ... }
extension Onboarding : BoolDefaultSettable { ... }
struct Constants { ... }
...
Account.set(true, forKey: .isUserLoggedIn)
Account.bool(forKey: .isUserLoggedIn)
こうすることによって、なんでも俯瞰してみることができます。しかし文脈についてはどうでしょう?UserDefaultsで isUserLoggedIn
に set trueを使う代わりに、Accountのset trueを使うようにすることでより文脈をもたせました。定数パターンにこだわらず、実際にデフォルトを作ってみることをおすすめします。
struct Defaults {
struct Account : BoolDefaultSettable { ... }
struct Onboarding : BoolDefaultSettable { ... }
}
...
Defaults.Account.set(true, forKey: .isUserLoggedIn)
Defaults.Onboarding.set(true, forKey: .isUserOnboarded)
Defaults.Account.bool(forKey: .isUserLoggedIn)
Defaults.Onboarding.bool(forKey: .isUserOnboarded)
最初よりかなり読みやすくなりましたね。
終わりに (16:23)
文字による型付けがされたAPIはよくありません。 使わないでください。
コードにはまだまだ改善の余地があります。我々は規約に挑戦し続け、何ができて何ができないかを探ろうとすべきでしょう。
定数のグループ化は必要ありませんが、僕は好きなのでします。自分のやることに統一性を保っていたいからです。
APIの命名は文脈をもたらします。Stringやキーも同様です。衝突を避けることにも役立ちます。
今プロトコルが熱いです。
最後になりますが、APIがどう動いているかは見直した方がいいでしょう。Appleから提供されているからといって、そのまま使う必要はありません。Appleが文字による型付けがなされたAPIを提供しているのは、それが我々がやりたいことすべての土台となるからです。TableViewのようにenumを用意できなかったのは、彼らには我々が何をコードで実現したいのかは決めておくことができないからです。
これを家を買うことに例えるのが好きです。入居すると、古い家具を処分したくなって、自分の家具を置き始めるのです。
About the content
2016年9月のtry! Swift NYCの講演です。映像はRealmによって撮影・録音され、主催者の許可を得て公開しています。