アプリ開発で、ユーザ入力が伴う画面では必ずと言っていいほど入力内容のバリデーションが必要です。ところが、もしかしてそのバリデーションのコード、読みにくすぎ?
例えばこんな要件があるとします:ユーザのメンバー ID を入力する画面があります。この ID は 10 桁以内の数字です。さてこれのバリデーションを書きましょうか:
func isIDValid(_ id: String) -> Bool {
guard !id.isEmpty else {
return false
}
guard Int(id) != nil else {
return false
}
guard id.count < 10 else {
return false
}
return true
}
validate("123") // true
validate("abc") // false
うーん読めなくはないですが、guard
文多いですね。今回のような単純な要件ならまあ読めなくはないですが…
あと書くのだるい…
もちろん、どうせ Bool
返してるだけなので、いっそのことこんな感じで書くのもいいですね:
func validate(_ id: String) -> Bool {
return !id.isEmpty
&& Int(id) != nil
&& id.count <= 10
}
validate("123")
validate("abc")
これならだいぶ書きやすくなります。ただやっぱ補完効きにくいのでまだ改善の余地はあるし、何よりそもそもの話、バリデーション結果を Bool
で返すのはなんかな…ですよね。
と言うわけで、これをこんな風に書きたいのです:
func validate(_ id: String) -> ValidationStatus<InvalidID> {
IDValidator.validate(id) { $0
.isNotEmpty()
.isInt()
.lessThan10Digits()
}
}
validate("123") // .valid
validate("abc") // .invalid(.notInt)
これではメソッドチェーンが使えるのでだいぶ書きやすいし、何よりなんのバリデーションか一目瞭然ですね!
では、これをどうやって作っているのかと言うと、簡単です。まずバリデーションの型を作ります:
protocol InvalidStatus: Equatable {
}
enum ValidationStatus<Invalid: InvalidStatus> {
case valid
case invalid(Invalid)
}
バリデーション状態は .valid
と .invalid
のみにし、具体的な .invalid
状態はさらに Invalid
で定義してもらいます。これのメリットは、大きなスコープで見た時は有効と無効だけで分けられるのでいろんな柔軟な対応がしやすい1し、具体的になぜ無効なのかが知りたいときのその無効状態を Invalid: InvalidStatus
から取り出せます。
次に、このバリデーションを実際にかける型を作ります。とは言え、どっちかと言うとこれはラッパーオブジェクトに近いですかね:
struct ValidationContainer<Target, Invalid: InvalidStatus> {
private let target: Target
private let invalid: Invalid?
private func finish() -> ValidationStatus<Invalid> {
if let invalid = invalid {
return .invalid(invalid)
} else {
return .valid
}
}
static func validate(_ target: Target, with validation: (Self) -> Self) -> ValidationStatus<Invalid> {
let container = Self.init(target: target, invalid: nil)
let result = validation(container).finish()
return result
}
func guarantee(_ condition: (Target) -> Bool, otherwise invalidStatus: Invalid) -> Self {
// If the container already has an invalid status, skip the condition check.
guard invalid == nil else {
return self
}
if condition(target) == true {
return self
} else {
return ValidationContainer(target: target, invalid: invalidStatus)
}
}
}
このコンテナは何かと言うと、現在のバリデーション対象と無効状態を保存して、そして次のバリデーションに回すためのものです。まずは無効状態なしで作って、そして次々と guarantee
を回して、もしどこか無効状態が途中であったらそれをそのまま返して、最後まで問題なかったら finish()
で .valid
を返せばいいです。
あれ? validate
メソッドに finish
は登場したけど guarantee
登場してなくない?と思うかもしれませんが、guarantee
は実際の利用する側に公開したメソッドで、それは validate
の validation: (Self) -> Self
と言うクロージャ内で利用してもらう予定だからです。
ちなみに struct
にも関わらず Self
が書けるのは、Swift 5.1 の新機能です2。
さて、ここまでで下準備ができました。ではどう利用すればいいでしょうか?まずは InvalidStatus
を作ります。今回は ID のバリデーションなので、InvalidID
を作りましょう:
enum InvalidID: InvalidStatus {
case empty
case notInt
case tooLong(maxCount: Int)
}
見ての通り、ID が空か、数字じゃないか、もしくは文字数が多すぎるの 3 パターンの無効判定があります。そしてその次、それぞれの判定を guarantee
を使って組み込みます:
extension ValidationContainer where Target == String, Invalid == InvalidID {
func isNotEmpty() -> Self {
return guarantee({ !$0.isEmpty }, otherwise: .empty)
}
func isInt() -> Self {
return guarantee({ Int($0) != nil }, otherwise: .notInt)
}
func lessThan10Digits() -> Self {
let maxDigits = 10
return guarantee({ $0.count <= maxDigits }, otherwise: .tooLong(maxCount: maxDigits))
}
}
これで 文字列は空文字列じゃない
、文字列は数字である
と 文字列は最大 10 文字以内
のバリデーションが書けました。最後は typealias
を定義してこれらを繋げば、読みやすいバリデーションが書けますね!
typealias IDValidator = ValidationContainer<String, InvalidID>
func validate(_ id: String) -> ValidationStatus<InvalidID> {
IDValidator.validate(id) { $0
.isNotEmpty()
.isInt()
.lessThan10Digits()
}
}
validate("123") // .valid
validate("abc") // .invalid(.notInt)
めでたしめでたし。
p.s. この書き方なんか見覚えあるぞ?と思ってくれた方もしいらっしゃったら、ありがとうございます!!そうですあの NotAutoLayout でも使ってたクロージャ×メソッドチェーンの組み方です