• 복잡한 문자열을 구조화된 데이터로 바꾸는 과정은 쉽지 않다.

    • String은 Collection이나 고차함수나, 인덱스 기반으로 할 수는 있겠지만, 썩 좋지 않다.

      let transaction = "DEBIT     03/05/2022    Doug's Dugout Dogs         $33.27"
      
      let fragments = transaction.split(whereSeparator: \\.isWhitespace)
      // ["DEBIT", "03/05/2022", "Doug\\'s", "Dugout", "Dogs", "$33.27"]
      
      ////
      
      var slice = transaction[...]
      
      // Extract a field, advancing `slice` to the start of the next field
      func extractField() -> Substring {
        let endIdx = {
          var start = slice.startIndex
          while true {
            // Position of next whitespace (including tabs)
            guard let spaceIdx = slice[start...].firstIndex(where: \\.isWhitespace) else {
              return slice.endIndex
            }
      
            // Tab suffices
            if slice[spaceIdx] == "\\t" {
              return spaceIdx
            }
      
            // Otherwise check for a second whitespace character
            let afterSpaceIdx = slice.index(after: spaceIdx)
            if afterSpaceIdx == slice.endIndex || slice[afterSpaceIdx].isWhitespace {
              return spaceIdx
            }
      
            // Skip over the single space and try again
            start = afterSpaceIdx
          }
        }()
        defer { slice = slice[endIdx...].drop(while: \\.isWhitespace) }
        return slice[..<endIdx]
      }
      
      let kind = extractField()
      let date = try Date(String(extractField()), strategy:  Date.FormatStyle(date: .numeric))
      let account = extractField()
      let amount = try Decimal(String(extractField()), format: .currency(code: "USD"))
      
  • 이런 문제를 해결하기 위해서 많은 언어에서는 정규표현식(regular expression, regex)를 쓴다.

    • 원래 형식언어 이론에서, 정규 언어를 정의하기 위해서 쓰던 것
    • 이후 컴파일러의 구문 분석, 커맨드라인 툴, 에디터 내에서의 검색 등에 활용되면서 이론적인 기반을 넘어서 많이 사용된다.
    • swift는 이를 더 발전된 형태로 사용하며, 이를 파생 regex라고 부른다.
  • 리터럴 형태의 regex

    let digits = /\\d+/
    // digits: Regex<Substring>
    
  • 런타임에 만들기

    // Run-time construction
    let runtimeString = #"\\d+"#
    let digits = try Regex(runtimeString)
    // digits: Regex<AnyRegexOutput>
    
  • builder 형태로 만들기

    // Regex builders
    let digits = OneOrMore(.digit)
    // digits: Regex<Substring>
    
  • split에 적용하기

    let transaction = "DEBIT     03/05/2022    Doug's Dugout Dogs         $33.27"
    
    let fragments = transaction.split(separator: /\\s{2,}|\\t/)
    // ["DEBIT", "03/05/2022", "Doug's Dugout Dogs", "$33.27"]
    
    // normalizing하기
    let normalized = transaction.replacing(/\\s{2,}|\\t/, with: "\\t")
    // DEBIT»03/05/2022»Doug's Dugout Dogs»$33.27
    
  • regex가 좋긴 한데, 쓰는 입장에서는 이해하기가 어렵다는 문제가 있다.

    • ‘문제를 해결하기 위해서 regex를 썼더니 이제는 문제가 2개가 됐다’라는 우스갯 소리도 있다.
  • swift는 4가지 영역에서 regex를 발전시켰다

    • 문법: 간결 or 암호같은? → 간결한 것은 리터럴로, 복잡한 것은 builder로
    • 텍스트 자료 구조의 본질 자체가 복잡해서, 다른 파서를 쓸 수 있어야 한다. → 다른 파서들과 섞어쓸 수 있다.
    • 아스키만 쓰던 시절부터 있던게 regex지만 , 유니코드도 다루어야 한다. → Swift regex는 유니코드를 지원한다.
    • regex의 동작은 강력하지만 예측하기 어렵다.
      • 이는 regex기본 동작이 최대한 매칭한뒤에, 뒤 표현식이 실패하면 돌아와서 하나씩 줄이는 식의 백트래킹 방식이기 때문이다.
      • 일부 언어에서는 이 백트래킹 범위를 조정하기 위한 컨트롤을 제공하지만, 문법이 복잡해지고 명시적이지 않다.
      • swift는 이를 예측가능하게 만들고, 컨트롤도 명시적으로 할 수 있게 된다.
  • 예시

    // CREDIT    03/02/2022    Payroll from employer         $200.23
    // CREDIT    03/03/2022    Suspect A                     $2,000,000.00
    // DEBIT     03/03/2022    Ted's Pet Rock Sanctuary      $2,000,000.00
    // DEBIT     03/05/2022    Doug's Dugout Dogs            $33.27
    
    import RegexBuilder
    let fieldSeparator = /\\s{2,}|\\t/
    let transactionMatcher = Regex {
      /CREDIT|DEBIT/
      fieldSeparator
      One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt))
      fieldSeparator
      OneOrMore {
        NegativeLookahead { fieldSeparator } // 해당 파서가 성공하면 자신을 감싸고 있는 파서를 멈춤
        CharacterClass.any
      }
      fieldSeparator
      One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US")))
    }
    
  • 값을 캡쳐하기.

    let fieldSeparator = /\\s{2,}|\\t/
    let transactionMatcher = Regex {
      Capture { /CREDIT|DEBIT/ }
      fieldSeparator
    
      Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) }
      fieldSeparator
    
      Capture {
        OneOrMore {
          NegativeLookahead { fieldSeparator }
          CharacterClass.any
        }
      }
      fieldSeparator
      Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) }
    }
    // transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>
    // 첫번째 Substring은 input 중 regex가 매치된 영역을 뜻한다.
    
  • 특정 데이터에 따라서 다른 데이터의 의미가 달라지는 경우가 있을 수 있는데…

    • 이런 이유 때문에 타입을 지정하지 않는 문자열로 그냥 저장하는 것을 선호하는 경우가 왕왕 있다.
    private let ledger = """
    KIND      DATE          INSTITUTION                AMOUNT
    ----------------------------------------------------------------
    CREDIT    03/01/2022    Payroll from employer      $200.23
    CREDIT    03/03/2022    Suspect A                  $2,000,000.00
    DEBIT     03/03/2022    Ted's Pet Rock Sanctuary   $2,000,000.00
    DEBIT     03/05/2022    Doug's Dugout Dogs         $33.27
    DEBIT     06/03/2022    Oxford Comma Supply Ltd.   £57.33
    """
    // 달러일 때는 mm/dd/yyyy
    // 파운드일때는 dd/mm/yyyy
    
  • 이런 경우에는 sed와 비슷한 문법을 regex에 적용할 수 있다. → named capture

    • extended delimiter를 쓰면, 이스케이핑 하지 않고도 슬래쉬 문자를 쓸 수 있다.
    • 또한 가독성을 위해서 공백을 무시하는 기능도 활성화된다.
    • 이름을 제공해서 regex 결과에 레이블을 제공할 수도 있다.
    let regex = #/
      (?<date>     \\d{2} / \\d{2} / \\d{4})
      (?<middle>   \\P{currencySymbol}+)
      (?<currency> \\p{currencySymbol})
    /#
    // Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
    
  • currency 결과를 보고 파싱 전략을 결정하기

    let regex = #/
      (?<date>     \\d{2} / \\d{2} / \\d{4})
      (?<middle>   \\P{currencySymbol}+)
      (?<currency> \\p{currencySymbol})
    /#
    // Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
    
    func pickStrategy(_ currency: Substring) -> Date.ParseStrategy {
      switch currency {
      case "$": return .date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)
      case "£": return .date(.numeric, locale: Locale(identifier: "en_GB"), timeZone: .gmt)
      default: fatalError("We found another one!")
      }
    }
    
  • 적용하기

    let regex = #/
      (?<date>     \\d{2} / \\d{2} / \\d{4})
      (?<middle>   \\P{currencySymbol}+)
      (?<currency> \\p{currencySymbol})
    /#
    // Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
    
    func pickStrategy(_ currency: Substring) -> Date.ParseStrategy { … }
    
    ledger.replace(regex) { match -> String in
      let date = try! Date(String(match.date), strategy: pickStrategy(match.currency))
    
      // ISO 8601, it's the only way to be sure
      let newDate = date.formatted(.iso8601.year().month().day())
    
      return newDate + match.middle + match.currency
    }
    
  • 유니코드 레벨의 매칭 → 기본적으로 유니코드 단위로 동작한다.

    switch ("🧟‍♀️💖🧠", "The Brain Cafe\\u{301}") {
    case (/.\\N{SPARKLING HEART}./, /.*café/.ignoresCase()): /
      print("Oh no! 🧟‍♀️💖🧠, but 🧠💖☕️!")
    default:
      print("No conflicts found")
    }
    
    let input = "Oh no! 🧟‍♀️💖🧠, but 🧠💖☕️!"
    
    input.firstMatch(of: /.\\N{SPARKLING HEART}./)
    // 🧟‍♀️💖🧠
    
    input.firstMatch(of: /.\\N{SPARKLING HEART}./.matchingSemantics(.unicodeScalar))
    // ️💖🧠, 첫번째 이모지의 마지막 unicode scalar
    
  • 캡처를 선택적으로 하기

    // CREDIT    <proprietary>      <redacted>        200.23        A1B34EFF     ...
    let fieldSeparator = /\\s{2,}|\\t/
    let field = OneOrMore {
      NegativeLookahead { fieldSeparator }
      CharacterClass.any
    }
    let transactionMatcher = Regex {
      Capture { /CREDIT|DEBIT/ }
      fieldSeparator
    
      TryCapture(field) { timestamp ~= $0 ? $0 : nil }
      fieldSeparator
    
      TryCapture(field) { details ~= $0 ? $0 : nil }
      fieldSeparator
    
      // ...
    }
    
  • 캡쳐 범위를 좁하기

    // CREDIT    <proprietary>      <redacted>        200.23        A1B34EFF     ...
    let fieldSeparator = Local { /\\s{2,}|\\t/ } 
    let field = OneOrMore {
      NegativeLookahead { fieldSeparator } // 뒤쪽 공백을 무시하게 만든다.
      CharacterClass.any
    }
    let transactionMatcher = Regex {
      Capture { /CREDIT|DEBIT/ }
      fieldSeparator
    
      TryCapture(field) { timestamp ~= $0 ? $0 : nil }
      fieldSeparator
    
      TryCapture(field) { details ~= $0 ? $0 : nil }
      fieldSeparator
    
      // ...
    }