태그 보관물: swift

RegularExpression-swift

Swift3에서 문자열 확장과 재명명된 API를 이용하여 간결하게 문자열 내의 특정 범위를 찾고, 문자열을 치환하는 테크닉에 대해 살펴본다.

Swift의 정규식

Swift는 언어 자체에서 정규식을 지원하지 않고 FoundationNSRegularExpression 클래스를 이용한다.

  1. NSRegulareExpressioninitthrows이기 때문에 try와 같이 사용되어야 한다.
  2. 매치 결과는 TextCheckingResult 클래스의 인스턴스를 얻게 된다. 이는 매치영역 및 영역 내 각 매치 그룹의 범위를 NSRange값으로 가지고 있다.
  3. 문제는 Swift 문자열의 부분문자열은 Index<String.Index>에 의해서 얻을 수 있지, NSRange를 이용할 수 없다. 따라서 이를 컨버팅하는 편의함수나 타입 확장을 이용해야 한다.

먼저 NSRange를 이용하여 부분 문자열을 구하게 하는 문자열 확장은 다음과 같다.

extension String {
  public func range(with r: NSRange) -> String.Index {
    let a = index(startIndex, offsetBy: r.location)
    let b = index(startIndex, offsetBy: r.location + r.length)
    return a..<b
  }

  public subscript(range: NSRange) -> String {
    return self[self.range(with:range)]
  }
}

또, 흔히 문자열 전체 범위에 대해서 탐색을 수행하는 경우가 많기 때문에 다음과 같은 확장을 하나 주는 것도 좋다.

extension String {
  public var fullRange: NSRange {
    return NSRange(location:0, length:characters.count)
  }
}

정규식 객체를 만드는 것은 패턴과 옵션을 이용한다.

옵션은 잘 쓰이지는 않지만 정리해보면 다음과 같다.

static class Options: OptionSet {
  static var caseInsensitive { get }
  static var allowCommentsAndWhiteSpace { get }
  static var ignoreMetacharacters { get }
  static var dotMatchesLineSeparators { get }
  static var anchorsMatchLines { get }
  static var useUnixLineSeparators { get }
  static var useUnicodeWordBoundaries { get }
}

특이한 점은 failable 생성자가 아닌 예외를 던지는 생성자를 쓴다는 점이다.


if let regex = try? RegularExpression(pattern:"\\d{1,4}:\\d{1,2}:\\d{1,2}", options:[]) {
  ...
}

검색

탐색에 쓰이는 메소드는 크게 네 가지로 나뉜다.

  • numberOfMatches(in:options:range:) -> Int
  • firstMatch(in:options:range:) -> TextCheckingResult?
  • matches(in:options:range:) -> [TextCheckingResult]
  • enumerateMatches(in:options:range:using)

기본적으로 검사할 문자열과 옵션, 검사범위를 주고 검사하는데, 1)매치의 수, 2)첫번째매치, 3)전체매치, 4)각 매치에 대해 반복작업지정의 동작을 수행한다.

매치 수 찾기

let str = "1234567890"
let pattern = "\\d{1,3}" // 숫자 1~3개 
let regex = try! RegularExpression(pattern:pattern, options:[])

let n = regex.numberOfMatches(in:str, options:[], range:str.fullRange) // 4 (123|456|789|0)

위 예제의 패턴은 숫자 1~3개의 매칭을 검사하는데, 한 번 스캔한 영역은 되돌아가서 다시 스캔하지 않으므로 최대 4개의 영역이 발생한다.

첫번째 매치 뽑기

let str = "1234567890"
let pattern = "\\d{3}(?=8)" // 8앞의 숫자 3개 --> 567 밖에 없다. 
let regex = try! RegularExpression(pattern:pattern, options:[])

if let n = regex.firstMatch(in:str, options:[], range:str.fullRange) {
  print(str[n.range]) // prints "567"
}

TextCheckingResultrange 속성은 패턴 전체가 매치하는 영역을 리턴한다. 만약에 패턴 내에 캡쳐링그룹이 정의되어 있다면 range(at:)을 이용하여 각각 그룹의 범위를 얻을 수 있는데, 이 때 0은 전체 범위, 1은 1번 그룹… 이런 식으로 정의될 수 있다.

전체 매치

정규식 패턴은 주어진 문자열 내에서 여러 번 매칭될 수 있다. 따라서 firstMatch(in:options:range:)와 거의 유사한 API로 matches(in:options:range)가 있다. 이는 TextCheckingResult의 배열을 리턴한다.

결과 순회

matches(in:options:range:)를 이용해서 전체를 검사한 각 결과를 순회하는 방법도 있지만, enumerateMatches(in:options:range:using:)을 써서 순회하는 방법도 있다.

이 때 넘겨주는 클로저의 타입은 @noescape (TextCheckingResult?, RegularExpression.MatchingFlag, UnsafeMutablePointer<ObjBool>) -> Void로, 각각 (result, flag, stop)이 된다. result는 탐색 결과를 담고 있고, flags는 매칭 처리에 사용된 옵션 정보를 담는다. stop은 불리언 값에 대한 포인터로 이 값을 true로 설정하면 더 이상 순회하지 않고 멈추게 된다.


let str = "123456789"
let regex = try! RegularExpression(pattern:"\\d{3}", options:[])
regex.enumerateMatches(in:str, options:[], range:str.fullRange){ result, flags, stop in
  print(str[result!.range(at:0)])
  stop.pointee = true // stop = true 가 되므로 더 이상 진행하지 않는다.
}

stop의 타입은 UnsafeMutablePointer<ObjBool> 타입인데 ObjBoolBool 타입으로 브릿징되므로 UnsafeMutablePointer<Bool>과 같다고 볼 수 있으며, .memory 프로퍼티는 .pointee로 보다 직관적인 이름으로 바뀌었다.

바꾸기

RegularExpression은 탐색 결과의 위치를 구하는 클래스이기 때문에, 문자열의 내용을 변경하는 기능은 제공하지 않는다. 하지만, 서브레인지의 범위를 알면 내용을 교체하는 것이 어렵지는 않다.

var str = "1234567890"
let regex = try! RegularExpression(pattern:"\\d{3}(?=8)", options:[])
if let match = regex.firstMatch(in:str, options:[], range:str.fullRange) {
  str.replaceSubrange(str.range(with:r), with:"abc")
}
print(str)
// "1234abc890

(Swift) Swift의 String타입 기초 – 02. 문자열 조작

목차

  1. 문자열 생성하기
  2. \* 문자열 조작하기
  3. 활용

문자열의 기본 조작

문자열 데이터를 다룰 때 가장 많이 쓰며, 또 중요한 스킬은 바로 문자열을 조작하는 것이다. 문자열 내의 특정 글자나 부분 문자열을 찾거나, 문자열에 어떤 글자를 추가, 삽입, 삭제, 변경하고, 문자열을 잘라서 나눈다던지 하는 등의 처리는 “간단한” 프로그램을 작성할 때 아주 많이 쓰이는 가장 기본적인 테크닉들이다.

또한 중요한 부분 중 하나는 문자열을 변경하는 작업은 크게 두 가지 타입으로 나뉘는데 하나는 원본 문자열 그 자체를 변경하는 것이고 다른 하나는 조작이 적용된 사본을 만드는 것이다. 이 장의 각 절에서는 이를 각각의 내용을 다뤄보도록 하겠다.

결합

두 개의 문자열을 하나도 합치는 것이다. 먼저 append(_:) 메소드는 Character, String 타입을 받을 수 있는데 전달한 내용을 현재의 문자열 뒤에 추가로 들여붙이는 기능을 한다.

appending(_:)은 문자열을 추가로 붙인 사본을 만드는 역할을 한다.1 사실 이렇게 2개 이상의 문자열을 결합하는 것은 + 연산자를 쓰면 된다.

appending(_:)의 친구로 appendingFormat(_:_:) 이 있는데, 이는 뒤에 붙이는 문자열을 포맷으로 부터 만들 수 있게 한다. (얼마나 필요가 있을지는 모르겠다.)2 이 외에도 append의 변형에는 append(contentsOf:)가 있어서 [Character]의 내용을 문자열 뒤에 붙이는 변형이 있다. 이는 insert(contentsOf:at:)의 형태로로 비슷하게 존재한다.

여러개의 문자열을 한꺼번에 결합하기

Array 타입의 메소드 중에 .joined(separator:) 가 있는데3 이는 원소타입이 String인 경우에 연결자를 중간에 삽입하여 문자열의 배열을 하나의 문자열로 결합하는 메소드이다. 많은 문자열을 append, + 등으로 결합하는 것보다 이를 사용하는 것을 권장한다.

let lines = [ ... ]
let paragraph = lines.joined(separator: "\n")

let words = [ ... ]
let line = words.joined(sepeartor: " ")

삽입

“추가”가 문자열의 맨 끝에 덧붙이는 거라면, 삽입은 중간에 어떤 지점에 하나 혹은 그 이상의 글자들을 끼워넣는 동작이다. 따라서 “어딘가에”를 가리킬 값이 필요한데, 앞에서도 말했다시피 문자열의 인덱스는 정수값이 아니기 때문에 여러모로 불편한 것이 함정. 사용하는 메소드는 insert(_:at:), insert(contentsOf:at:) 이며, 후자는 다른 문자열 혹은 문자의 집합(Array<Character>)을 삽입한다.

var s = "hello world type here"
let i = s.index(s.startIndex, offsetBy: 6)

// 혹은 특정 글자를 찾아서 그 인덱스를 쓴다.
// let i = s.characters.index(of:"w")

s.insert("W", at: i) /// hello Wworld type here

// 다음과 같이 문자의 집합을 쓸 수도 있다. 
s.insert(contentsOf: "Wow".characters, at: i)
print(s)

삭제

문자열 내의 특정 위치의 글자(Character)나 특정 부분범위의 내용을 삭제하는 작업이다. 4개의 메소드가 이와 관련되는데 모든 메소드는 mutating이며 사본을 생성해주는 버전은 없다.

  • remove(at:) 특정 글자 하나를 삭제
  • removeAll(keepCapacity:) 전체를 삭제하고 빈 문자열로 만든다. keepCapacity는 디폴트로 false가 넘어가는데, 이를 true로 넘겨주면 이미 할당된 메모리 스토리지를 그대로 유지하기 때문에 글자를 다시 append하여 채워넣을 때 효율을 높일 수 있다.
  • removeSubrange(_: Range<String.Index>) 특정 범위의 글자들을 삭제한다.
  • removeSubrange(_: ClasedRange<String.Index>) 위 메소드의 같은 버전. upper bound를 포함하는 ClosedRange 를 썼을 때 호출된다.

특정 문자/문자열 포함 여부

특정 글자가 포함되어 있는지를 알고 싶다면 문자열의 CharacterView 를 이용할 수 있다. 여기에 index(of:)가 정의되어 있다. 4 단지, 그 여부값만 알고 싶다면 contains(_:)를 써도 된다.

하지만 이 기능은 매우 제한적이다. 왜냐면 특정한 글자가 여러 개 들어있는 긴 문자열에서 항상 첫 번째 등장하는 위치만 알수 있기 때문이다.

더군다나 문자열에서는 특정한 문자하나 뿐만 아니라, 특정한 문자열이 포함되는지, 특정한 부분 문자열이 어디에 있는지를 알고 싶은 경우가 더 많다.

만약 문자열로 문자열 내부를 검색하고 싶다면 다음과 같이 확장 메소드를 추가해준다. (Foundation에 의존하지 않는다.)

extension String {
    func search(of target: String) -> Range<Index>? {
        // 찾는 결과는 `leftIndex`와 `rightIndex`사이에 들어가게 된다.
        var leftIndex = startIndex
        while true {
            // 우선 `leftIndex`의 글자가 찾고자하는 target의 첫글자와 일치하는 곳까지 커서를 전진한다.
            guard self[leftIndex] == target[target.startIndex] else {
                leftIndex = index(after:leftIndex)
                if leftIndex >= endIndex { return nil }
                    continue
            }
            // `leftIndex`의 글자가 일치하는 곳이후부터 `rightIndex`를 늘려가면서 일치여부를 찾는다.
            var rightIndex = index(after:leftIndex)
            var targetIndex = target.index(after:target.startIndex)
            while self[rightIndex] == target[targetIndex] {
                // target의 전체 구간이 일치함이 확인되는 경우
                guard distance(from:leftIndex, to:rightIndex) < target.characters.count - 1
                else {
                    return leftIndex..<index(after:rightIndex)
                }
                rightIndex = index(after:rightIndex)
                targetIndex = target.index(after:targetIndex)
                // 만약 일치한 구간을 찾지못하고 범위를 벗어나는 경우
                if rightIndex >= endIndex {
                    return nil
                }

            }
            leftIndex = index(after:leftIndex)
        }
    }
}

let s = "hello world this is sample string"
if let r = s.search(of:"sample") {
    print(s[r])
}

https://swiftlang.ng.bluemix.net/#/repl/5819a6c5f9f5f14d876a3052

이 코드는 원본 문자열 내에 좌/우 인덱스를 이용해서 좌 인덱스가 찾고자 하는 타깃의 첫글자와 같은지를 검사한 후, 순차적으로 타깃의 끝까지 검사하는 방식을 이용한다.

하지만 보다 확실한 방법은 정규식을 활용하는 것이다. 정규식으로 부분문자열을 찾는 예제

단순히 부분문자열이 내부에 존재하는지 여부만 알고 싶다면, NSStringcontains(_:)를 쓸 수 있다. 또한 위에서 만든 search(of:)보다 좀 더 많은 기능을 제공하는 메소드가 NSString에 이미 구현돼 있는데, 바로 range(of:options:range:locale:)이다. 이 메소드에서 range 값을 순차적으로 만들어서 탐색하면 문자열 내의 모든 부분문자열의 출현 구간을 찾을 수 있을 것다.

정규 표현식

문자열 내의 특정 문자열을 찾고 치환하는 등의 작업은 사실 정규식(NSRegularExpression)을 이용하는 것이 가장 간편하고 확실한 방법이라 할 수 있다. 다만 정규 표현식을 사용할 때의 매칭은 주의를 기울여야 하는 부분이 있다.

  1. 매칭 결과는 범위가 아니라 NSTextCheckingResult 이다. 정규식 패턴은 1개 이상의 그룹을 포함할 수 잇기 때문에 매치 1개에는 여러 개의 범위가 있을 수 있다. 다만, 전체 매칭 영역은 0번 범위에 있다.
  2. 검색된 결과가 없어도 매칭 결과가 나올 수 있다. 예를 들어 rangeOfFirstMatch(in:options:range:)의 리턴값은 매칭 결과가 없을 때 nil이 아니라 {NSNotFound, 0}이다. 이 부분을 잘 체크해야 한다.
  3. 매칭 범위값이 Range<String.Index>가 아니라 NSRange이다. 따라서 이를 String에 적용하기 위해서는 별도의 변환이 필요하다.

정규 표현식을 이용하는데는 NSRange와 관련하여 보다 쉽게 처리를 위해서 다음과 같이 확장을 붙여주는 것이 좋다.

extension String {
    var fullRange: NSRange { return NSRange(location:0, length:characters.count)}
    func range(from r:NSRange) -> Range<Index> {
        let s = index(startIndex, offsetBy: r.location)
        let e = index(s, offsetBy: r.length)
        return s..<e
    }
}

문자열에 정규식을 사용하는 방법과 관련해서는 Foundation쪽의 수정이 더 필요해 보인다. 정규식 사용과 관련한 부분은 나중에 별도 포스팅에서 더 다뤄보도록 하겠다.

치환

String의 기본 치환 메소드는 replaceSubrange(_:with:) 로 치환내용의 타입이 문자열이거나, [Character] 타입을 받는 오버로딩들이 있다. 기본 타입의 치환 메소드는 모두 mutating 메소드이며, 변경한 사본을 얻으려면 NSString의 메소드들인 replacingCharacters(in:with:), replacingOccurences(of:with:options:range:) 를 이용한다.

정규 표현식

정규 표현식으로 문자열을 치환하는 메소드로는 replaceMatches(in:options:range:withTemplage:)stringByReplacingMatches(in:options:range:withTemplate)이 있다. 전자의 경우에는 in: 으로 넘겨지는 문자열이 변경되는데, NSMutableString을 받으므로 결국은 변경된 사본을 만드는 것과 별 차이가 없다. 참고로 StringNSMutableString으로는 브릿징되지 않는다. NSMutableString(string:)을 사용해서 사본을 만들어야 한다.

인덱스

문자열 인덱스 타입은 문자열 내에서 의미를 갖는 타입이므로, 인덱스 조작 관련 연산이 문자열의 인스턴스 메소드로 구현되어 있다.

  • index(_:offsetBy:) – 주어진 인덱스로부터 떨어진 위치의 인덱스
  • index(_:offsetBy:limitedBy:) – 주어진 인덱스로부터 떨어진 위치의 인덱스를 찾는데, 해당 값이 특정 인덱스를 벗어나지 않게 한다.
  • index(after:) – 주어진 인덱스의 바로 다음 인덱스
  • index(before:) – 주어진 인덱스의 바로 직전 인덱스

문자열의 인덱스 혹은 인덱스의 범위(Range<String.Index>)는 문자열을 서브스크립팅하여 n 번째 글자 혹은 특정 구간의 부분열을 액세스하려고 할 때 사용된다. Swift에서 문자열을 사용할 때 가장 성가신 부분이 바로 이 부분이다. 직관적으로는 n번째 글자를 얻으려는 것이기 때문에 양의 정수값을 사용해도 무리가 없지 않겠냐고 생각된다. 아마도 이렇게 한 이유는 String타입은 내부적으로 UnicodeScalar 시퀀스로 콘텐츠를 저장하는데, 유니코드 중 일부 코드들은 그 자체로 하나의 글자가 되기 보다는 합자를 만든다. 따라서 하나의 글자가 2개 이상의 유니코드 스칼라값으로 구성될 소지가 있다. 예를 들어 흔히 café를 쓸 때 엑센트가 붙은 e의 코드값은 00E9이다. 이는 다음과 같이 출력해볼 수 있다.

print("\u{00E9}") // é

이 엑센트 기호의 기본 코드값은 U+00B4인데, 이 때는 하나의 글자로 표현되는 ´문자를 뜻한다. 이외에도 U+301이 정의되어 있는데, 이 이름은 Combining Acute Accent이다. 즉 다른 글자와 합쳐져서 합자를 이룰 때 쓴다는 말이다.

print("e\u{0301}") // é

위 두 예제에서 출력되는 글자는 é으로 동일하지만 두 문자열은 내부적으로 다른 코드값으로 구성되어 있다. 첫번째 예의 문자열은 이미 합자 상태로 정의된 문자의 코드이며, 두 번째 글자는 e´이 합쳐져서 하나의 글자가 된다는 점을 보이는 것이다.

이와 비슷한 문제는 사실 한글에도 있을 수 있다. 자를 살펴보자. 의 유니코드 테이블상의 코드값은 U+C6444이다. 다들 알겠지만 이 글자는 ㅇ ㅘ ㄴ의 세 글자가 합쳐진 글자이고, 이 세 글자의 코드값은 각각 U+3147, U+3158, U+3134이다.5 물론 이 세 코드값을 나란히 사용하여 만든 문자열은 이 아니라 ㅇㅘㄴ이 된다.

print("\u{3147}\u{3158}\u{3134}") // ㅇㅘㄴ

유니코드의 한글 자모는 Hangle Jamo라는 이름의 블럭에 정의되어 있으며 여기에는 문자의 특성을 반영하여 초성, 중성, 종성을 구분한다. 따라서 초성에 오는 과 종성에 오는 은 별개의 코드값을 가지며, 이들은 특정한 알고리듬에 의해 조합되어 미리 조합된 한글 낱자 세트인 Hangle Syllables 블럭의 각 낱자에 맵핑될 수 있다. 한글 자모 블럭에서 의 각 소리글자들을 모으면 110B, 116A, 11AB이다.

print("\u{110b}\u{116a}\u{11ab}") // 완

Swift 문자열은 이러한 합자를 구성하는 알고리듬을 내장하고 있고, 따라서 합자가 가능한 코드값이 연속적으로 이어지는 경우, 이를 개별 낱자로 표현하는 것이 아니라 합쳐진 글자로 변환하여 구성한다.

let wan = "\u{110B}\u{116a}\u{11ab}"
print(wan)                              // 완
print(wan.characters.count)             // 1
print(wan.unicodeScalars.count)         // 3

따라서 데이터 레벨에서는 서로 다른 문자열들이 글자 레벨에서는 결국 같은 값일 수 있고, 그 때문에 문자열의 길이는 문자열의 스토리지의 크기와 항상 일치하지 않으며, 합자가 나타나는 위치는 미리 알 수 없기 때문에 정수 인덱스를 사용하지 못하게끔하고 있는게 아닌가 하는 생각은 든다. 하지만 그럼에도 불구하고 String 타입은 내부적으로 CharacterView와 UnicodeScalarView를 따로 가져가고 있고, 실질적으로 String.IndexString.CharacterView.Index이기 때문에 정수 인덱스를 사용한 서브 스크립션이 문제가 될 것 같지는 않다.

어쨌든 이 부분은 Swift4에서 보다 편리하고 직관적인 사용이 가능하도록 re-evolve하겠다고 공언하였으니, 한 번 지켜보도록 하자.


  1. 동사원형을 쓰는 경우는 대부분 mutating이며 사본을 만드는 변경은 ~ing, ~ed 등으로 처리한다. 
  2. 이름을 봐선 조만간에 appending(format:_:)으로 바뀔 거 같은 예감이다. 
  3. 물론 원소가 문자열이 아닐 때에는 Element의 타입이 같은 시퀀스를 연결자로 하여 배열의 배열을 결합한 하나의 배열을 만드는 이름이 같은 메소드가 있다. 참고 
  4. 과 같이 한글 자모의 조합으로 만들어지는 낱자들은 유니코드 테이블 상에서 Hangul Syllables라는 이름으로 AC00 ~ D7AF범위를 차지하는 11184자이다. 
  5. , , 과 같이 조합되지 않는 낱개의 자모는 EUC-KR과의 호환을 위해서 별도로 분리하여 Hangul Compatibility Jamo라는 블럭으로 등재되어 있다.