しおメモ

雑多な技術系ブログです。ニッチな内容が多いです。

Swift4で範囲の文字列表現をRangeに落とし込む

正確には、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

このprotocolcontainsを持つのですが、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していきます。
その前に、TStringからの変換関数が欲しいので、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ならば、良い感じに変換できます。

最後に

Javainterfaceみたいに、protocolGenericsをサポートして欲しい🤔