正確には、RangeExpression
のような、contains
で要素を含むかどうかを判定できる適当なクラスに落とし込みます。
今回はRange
(0.0...1.0みたいなやつ)だけではなく、RangeExpression
に適合するのすべてのクラスに対応したいと思います。
Swift4.2からRange
系が変わっているようなので、それに則ります。
やりたいこと
こういう感じの文字列をRange
のような感じのクラスにパースしたい。
<1.1.2 <=2018-11-11T00:00 2017-1-1T00:00<2018-11-11T00:00 # 良い記法かは微妙
RangeExpression
SwiftのRange
系のクラスは、RangeExpression
プロトコルを実装しています。
RangeExpression - Swift Standard Library | Apple Developer Documentation
このprotocol
はcontains
を持つのですが、Swiftのassociatedtype
の制約上、RangeExpression<T>
のようなインスタンスや戻り値を作ることができません。
型消去を使って、contains
を持つRangeExpression
相当のクラスを作ることもできますが、あまりシンプルにならないので、今回はラッパークラスを実装します。
GeneralRangeの実装
Swiftは[0,1)
や[0,∞)
のような範囲も表せるので、それに対応したクラスを作ります。
final class GeneralRange<Bound: Comparable> { let left: Bound? let right: Bound? let isContainingRightSide: Bool init(lhs: Bound? = nil, rhs: Bound? = nil, isContainingRightSide: Bool = true) { left = lhs right = rhs self.isContainingRightSide = isContainingRightSide } func contains(_ target: Bound) -> Bool { /** 実装 */ } }
このように、左右の境界値をOptional
で持ち、nil
の際には境界なしというようにプロパティを用意します。
また、右辺に関しては、等号を含める場合と含めない場合があるので、Bool
で持ちます。
contains
の実装はこのような感じです。
func contains(_ target: Bound) -> Bool { switch (left, right, isContainingRightSide) { case (.some(let left), .some(let right), true): return (left...right).contains(target) case (.some(let left), .some(let right), false): return (left..<right).contains(target) case (.some(let left), .none, _): return (left...).contains(target) case (.none, .some(let right), true): return (...right).contains(target) case (.none, .some(let right), false): return (..<right).contains(target) case (.none, .none, _): return true } } }
車輪の再発明感がすごいですが、ゴリ押しでRangeExpression
に落とし込み、contains
を呼びます。
Parserの実装
RangeParsableの定義
パーサーといっても大したことはやらないですが、validateして、<
でsplit
していきます。
その前に、T
にString
からの変換関数が欲しいので、protocol
を定義しておきます。
protocol RangeParsableFactory { associatedtype ImplType static func from(_ str: String) -> ImplType? } protocol RangeParsable: Comparable, RangeParsableFactory where ImplType == Self {}
イニシャライザでもうまくやれば問題ないのですが、コンパイラが被りにうるさいので、今回はstatic factoryを採用しました。
parseの実装
本題のparse
を実装していきます。RangeParser
クラスを用意して、そのメソッドとして書いていきます。
func parse<T: RangeParsable>(from str: String, to: T.Type) -> Result<GeneralRange<T>, ParseError> { let pattern = str.remove(" ") guard pattern.isMatching(regex: "^[^<=]*[<]?[=]?[^<=]+$") else { return .error(.invalidRangeFormat(pattern)) } let split = pattern.remove("=").split(separator: "<") switch (split.count, pattern.contains("<"), pattern.contains("=")) { case (1, true, true): guard let rhs = T.from(String(split[0])) else { return .error(.invalidValueFormat(str)) } return .ok(GeneralRange(lhs: nil, rhs: rhs, isContainingRightSide: true)) case (1, true, false): guard let rhs = T.from(String(split[0])) else { return .error(.invalidValueFormat(str)) } return .ok(GeneralRange(lhs: nil, rhs: rhs, isContainingRightSide: false)) case (1, false, _): guard let value = T.from(String(split[0])) else { return .error(.invalidValueFormat(str)) } return .ok(GeneralRange(lhs: value, rhs: value, isContainingRightSide: true)) case (2, _, _): guard let lhs = T.from(String(split[0])), let rhs = T.from(String(split[1])) else { return .error(.invalidValueFormat(str)) } return .ok(GeneralRange(lhs: lhs, rhs: rhs, isContainingRightSide: pattern.contains("="))) default: return .error(.invalidRangeFormat(str)) } } fileprivate extension String { func remove(_ str: String) -> String { return self.replacingOccurrences(of: str, with: "") } }
正規表現で<と=の個数だけチェックして、<でsplit
します。
split
した結果が1つか2つだったら、先ほどのGeneralRange
に落とし込みます。
その際に、split
したString
からの変換がうまくいくかは、static factoryに任せます。
Equatable
はこのようになります。
extension GeneralRange: Equatable { public static func == (lhs: GeneralRange<T>, rhs: GeneralRange<T>) -> Bool { return lhs.left == rhs.left && lhs.right == rhs.right && lhs.isContainingRightSide == rhs.isContainingRightSide } }
エラー定義や、extension
はこんな感じです。
/// 正規表現チェック用 extension String { func match(regex: String) -> Int? { guard let regex = try? NSRegularExpression(pattern: regex) else { return nil } return regex.numberOfMatches(in: self, range: NSRange(location: 0, length: count)) } func isMatching(regex: String) -> Bool { if let count = match(regex: regex), count > 0 { return true } else { return false } } }
enum ParseError: Error { /// Invalid range format. (e.g. 0.1<) case invalidRangeFormat(String) /// Invalid value format. (e.g. 0.1..2<1.2) case invalidValueFormat(String) }
Dateで使ってみる
RangeParsableの追加
Date
は元からComparable
なので、extension
でstatic factoryを追加してRangeParsable
を実装します。
extension Date: RangeParsable { static func from(_ str: String) -> Date? { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" dateFormatter.locale = Locale.current return dateFormatter.date(from: str) } }
実際にパース
(スペースの都合上、forced unwrap
を使用しています🙇)
let parser = RangeParser() let parseResult = parser.parse(from: "<2018-12-01T00:00", to: Date.self) guard let range = parseResult.ok() else { return } range.contains(Date.from("2015-10-10T00:00")!) // true range.contains(Date.from("2018-11-12T00:00")!) // true range.contains(Date.from("2018-11-30T00:00")!) // true range.contains(Date.from("2018-12-1T00:00")!) // false range.contains(Date.from("2019-1-1T00:00")!) // false
このような感じで、Comparable
ならば、良い感じに変換できます。
最後に
Java
のinterface
みたいに、protocol
もGenericsをサポートして欲しい🤔