Swift의 문자열과 NSRange을 혼용하는 방법에 대해

NSRangeNSString의 서브스트링의 영역을 표시하기 위해 정의된 구조체 타입이다.1 이는 문자열 내의 특정 범위를 가리키기 위해 부분 범위가 시작하는 위치와 그 범위의 크기를 정의한다.

NSString -- location : 시작 위치의 오프셋
         -- length : 범위의 길이

NSString에서 부분 문자열 얻기

NSString-substringWithRange:를 통해서 NSRange가 가리키는 범위의 부분열을 얻을 수 있다. 이는 Swift에서는 다음과 같이 브릿징되어 사용된다.

import Foundation 
let hello: NSString = "hello,world!" 
let r = NSMakeRange(2, 4)  
// hello,world! 
//   ^^^^ 

let subHello = hello.substring(with: r) 
print(subHello) // llo,

그런데 Swift에서 위 코드를 실행했을 때 subHello의 리턴타입은 NSString이 아니라 String이다. 이는 Foundation 프레임워크가 Swift로 반입될 때, substringWithRange:의 리턴타입이 원래 NSString이었던 것이 브릿징되어 String을 리턴하는 것으로 변경되었기 때문이다.

Swift 문자열과 NSRange

그렇다면 Swift의 문자열 타입인 String에 대해서도 Foundation에서는 똑같이 생긴 메소드가 확장을 통해서 정의되어 있다.2

func substring(with aRange: Range<String.Index>) -> String

다만 이것은 NSString의 그것과 이름이 같을 뿐이지, NSRange 를 사용하는 것이 아니라 문자열의 각 글자의 인덱스의 범위(Range<String.Index>)를 사용하므로 이 API에서는 NSRange를 사용하지 않는다.

Swift 문자열에서 NSRange로 부분열 얻기

따라서 Swift 문자열에서 NSRange를 통해서 부분열을 얻기 위해서는 NSString을 통해서 간접적으로 얻는 방법밖에 없다. 현재는 StringNSString은 엄연히 다른 타입3이며 따라서 다음과 같이 NSString 타입의 사본을 만들어서 처리해야 한다. (물론, 그 결과는 String 타입이다. )

import Foundation 
let hello: String = "hello,world!" 
let r = NSMakeRange(2, 4) 
let subHello: String = (NSString(string: hello)).substring(with: r) 
print(subHello) // llo,

 

Swift와 NSRange의 접점은 없나

NSString을 쓸 때는 NSRange를 쓰고, String을 쓸 때는 Range<String.Index>를 쓰는 것으로 정리를 다해버리면 좋겠지만, 아직까지 Foundation의 브릿징에는 허점이 존재한다. 위에서 살펴본 바와 같이 NSRangeRange<String.Index>는 철저하게 적용되는 문자열의 타입을 구분하고 있다. 그리고 많은 경우에 NSStringString은 API 상에서 서로 호환되고 있어서, 문자열 타입에 맞는 범위 표현 타입을 쓰면 모두가 해피해지면 좋겠지만…

늘 예외란게 있기 마련이다. 예를 들어 정규식같은 것을 보자.

NSRegularExpression 클래스를 이용하여 정규식으로 문자열의 특정 영역을 매치하는 경우,  Foundation의 API 브릿징에 의해서 문자열에 대한 타입은 늘  String 타입을 쓸 수 있지만, 매치 결과의 범위는 NSRange로 리턴된다.  그러니까, NSRangeRange<String.Index>는 서로간에 브릿징되지 않는다. 따라서 다음과 같은 문제가 발생할 수 있다.

  1. String 타입의 긴 문자열이 있다고 가정한다.
  2. 정규식을 이용해서 특정한 패턴에 매칭하는 부분열을 찾아내고 싶다.
  3. NSRegularExpressionrangeOfFirstMatch(in:options:range:)를 사용하려 한다.

이때 해당 메소드에 넘겨지는 인자 중에 in: 부분에는 문자열타입 그대로를 쓸 수 있다. (브릿징되니까) 그런데 range: 부분의 파라미터 타입은 여전히 NSRange이며 이 타입은 브릿징되지 않는다.

더군다나 해당 메소드는 문자열 내에서 정규식 패턴에 일치하는 첫번째 매치의 영역값을 리턴하고 이 타입 역시 NSRange이다. 따라서 매칭되는 부분문자열 자체를 구하고 싶다면, 다시 원래의 문자열을 NSString으로 변환하거나, NSRange의 각 요소를 사용해서 String의 부분열을 구하는 등의 번거로운 작업이 남게 된다.

API의 변화 가능성을 생각해보자

이러한 상황을 개성하려면 결국 API의 추가적인 개선이 필요해 보인다. 다음과 같은 식으로 몇 가지 방향을 잡을 수 있을 것이다.

  1. Swift 네이티브 문자열을 위한 정규식 사용 API를 별도로 개발한다.
  2. NSRangeRange<String.Index>를 브릿징한다.
  3. String에서 NSRange를 사용할 수 있게 확장한다.

Swift만의 정규식 API를 새롭게 만들었으면 좋겠다는 의견도 웹상에서는 제법 보인다. 특히 루비에 대한 경험이 있는 개발자들이 루비의 정규식 패턴매칭 테스트 문법이 간결하다고 생각하는 것 같다. 하지만 기존 Foundation 코드와의 호환성을 유지하면서 문법적으로 보다 간단하고 자유로운 정규식 API를 디자인하기란 쉽지 않아보이며, 그런 이유로 아직까지 이에 대한 proposal은 보이지 않는다. (사실 기존 NSString API로부터 간단한 매칭은 좀 더 쉽게 구현할 수 도 있고)

두번째로 NSRangeRange<String.Index>를 브릿징하는 것도 어려울 것이다. NSRange는 단순히 두 개 정수의 값을 붙여놓은 C  구조체임에 비해서, String.Index는 유니코드 글자의 시작 인덱스를 말한다. 유니코드 글자 1개의 크기가 일정하지 않기 때문에 String.Index는 문자열 내에서도 일정한 간격을 가진 연속적인 값이 아니다. 따라서 String.Index는 실질적으로 String의 콘텐츠 자체에 의존적일 수 밖에 없으며, 따라서 String 없이 이 둘을 기계적으로 브릿징하기는 불가능해 보인다.

세번째 방법은 조금 귀찮기는 하지만, 문자열을 확장하여 NSRange를 통해서 부분열을 구할 수 있게 확장해주는 것이다.

String에서 NSRange를 통해서 부분열 만들기

기본적으로 String 타입은 Range<String.Index>를 통해서 부분열을 만들 수 있다. 특정 순번의 인덱스는 정수값으로 바로 지정할 수 없고 시작 인덱스 및 끝 인덱스를 앞/뒤로 밀고 당겨서 구해야 한다. 이를 위해서 String은 index(_:offsetBy:) 라는 편의 메소드를 제공해준다. 이걸 이용해서 아래와 같이 문자열을 확장해보자.

extension String {
  public subscript(aRange: NSRange) -> String {
    let start = index(startIndex, offsetBy: aRange.location)
    let end = index(start, offsetBy: aRange.length)
    return self[start..<end]
  }
}

사실 이 코드에서 간과한 부분이 있는데, NSRange는 해당 범위를 찾을 수 없을 때, location 값에 NSNotFound를 갖는다. 이 경우에 대한 처리를 하지 않고 최소한의 방법만 소개한 것이니 참고하자.

또한 RegularExpression 타입을 사용하는데는 검사시에 해당 문자열의 전체 범위를 주는 경우가 많은데, 이 경우에도 NSRange(index:0, length:str.characters.count)와 같은 번잡한 코드가 아닌 깔끔한 코드를 위해서 확장하는 김에 다음과 같이 범용 프로퍼티를 추가하는 것도 나쁘지 않겠다.

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

간단한 예제

다음은 정규식을 사용해서 부분열의 범위를 찾고, 다시 해당 범위를 통해서 부분열을 얻어 출력하는 예제이다.

import Foundation

extension String {
  // NSRange를 사용해서 부분열을 얻을 수 있도록 문자열을 확장
  public subscript(aRange: NSRange) -> String {
    let start = index(startIndex, offsetBy: aRange.location)
    let end = index(start, offsetBy: aRange.length)
    return self[start.. <end]
  }

  pubic var fullRange: NSRange {
    return NSMakeRange(0, characters.count)
  }
}

// 아래 문자열을 합성 글리프를 적용한 예로, cafe?는 총 여섯글자입니다.
let s = "Voulez-vous un caf\u{65}\u{301}? Que diriez-vous ce soir?"
print(s)
    // Voulez-vous un café? Que diriez-vous ce soir?

if let re = try ? NSRegularExpression(pattern: "\\b\\w*?\\?", options: []) 
{
    let firstMatchRange = re.rangeOfFirstMatch( in : s, options: [], range: s.fullRange)

    print(firstMatchRange)
        // _NSRange(location: 15, length: 6)

    // NSString을 이용해서 부분열 구하기
    print((NSString(string: s)).substring(with: firstMatchRange))

    // String + NSRange로 부분열 만들기
    print(s[firstMatchRange])
}

http://swift.sandbox.bluemix.net/#/repl/5951c33a3bb27f5d888b1e7a

그 외의 방법

보너스로 한가지 더 언급해보자면, Foundation 내에는 다음과 같은 확장 메소드가 하나 정의되어 있다.4

func range(of: options: range: locale: ) -> Range<String.Index>?

이 중에서 range:, locale: 파라미터는 디폴트가 적용되어 있으므로 생략가능하며, options: 에 들어가는 파라미터는 String.CompareOption인데 이 중에 .regularExpression을 적용하면 정규식 패턴을 이용해서 찾는다. 이 메소드의 리턴타입은 Range<String.Index>? 이므로 보다 수월하게 탐색하고, 탐색의 결과를 활용할 수 있다.

if let cafeRange = s.range(of: "\\b\\w.*?\\?", options:[.regularExpression])
{
  print(s[cafeRange])
}  // café?

  1.   Struct가 아닌 그냥 C 구조체 타입이며, 이 둘은 호환되는 관계가 아니다. NSRect의 경우에도 기존에는 C 구조체였으나, 워낙 자주 쓰이다 보니 이 경우는 아예 Struct로 재작성됐다. 
  2. 따라서 import Foundation 을 해주지 않으면 없는 없는 속성이라는 에러가 뜨게 된다. 
  3. 상호호환이 된다는 것은 API의 브릿징을 의미하는 것이다. -substringWithRange: 의 예에서 보듯이 Swift 내에서 NSString을 사용하는 경우에 관련되는 함수나 메소드에서 NSString 타입 대신에 String을 인자로 줄 수 있고, 또 리턴값으로 던져주도록 API가 변환되는 범위에서 호환을 말한다. 실질적인 두 타입간의 as 연산자를 통한 캐스팅은 macOS/iOS 플랫폼에서만 가능하며, 리눅스 플랫폼의 Swift에서는 이 캐스팅을 지원하지 못한다. 
  4. https://developer.apple.com/documentation/swift/string/1642786-range