Swift의 문자열과 NSRange을 혼용하는 방법에 대해
NSRange
는 NSString
의 서브스트링의 영역을 표시하기 위해 정의된 구조체 타입이다.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
을 통해서 간접적으로 얻는 방법밖에 없다. 현재는 String
과 NSString
은 엄연히 다른 타입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의 브릿징에는 허점이 존재한다. 위에서 살펴본 바와 같이 NSRange
와 Range<String.Index>
는 철저하게 적용되는 문자열의 타입을 구분하고 있다. 그리고 많은 경우에 NSString
과 String
은 API 상에서 서로 호환되고 있어서, 문자열 타입에 맞는 범위 표현 타입을 쓰면 모두가 해피해지면 좋겠지만…
늘 예외란게 있기 마련이다. 예를 들어 정규식같은 것을 보자.
NSRegularExpression
클래스를 이용하여 정규식으로 문자열의 특정 영역을 매치하는 경우, Foundation의 API 브릿징에 의해서 문자열에 대한 타입은 늘 String
타입을 쓸 수 있지만, 매치 결과의 범위는 NSRange
로 리턴된다. 그러니까, NSRange
와 Range<String.Index>
는 서로간에 브릿징되지 않는다. 따라서 다음과 같은 문제가 발생할 수 있다.
- String 타입의 긴 문자열이 있다고 가정한다.
- 정규식을 이용해서 특정한 패턴에 매칭하는 부분열을 찾아내고 싶다.
NSRegularExpression
의rangeOfFirstMatch(in:options:range:)
를 사용하려 한다.
이때 해당 메소드에 넘겨지는 인자 중에 in:
부분에는 문자열타입 그대로를 쓸 수 있다. (브릿징되니까) 그런데 range:
부분의 파라미터 타입은 여전히 NSRange
이며 이 타입은 브릿징되지 않는다.
더군다나 해당 메소드는 문자열 내에서 정규식 패턴에 일치하는 첫번째 매치의 영역값을 리턴하고 이 타입 역시 NSRange
이다. 따라서 매칭되는 부분문자열 자체를 구하고 싶다면, 다시 원래의 문자열을 NSString
으로 변환하거나, NSRange
의 각 요소를 사용해서 String
의 부분열을 구하는 등의 번거로운 작업이 남게 된다.
API의 변화 가능성을 생각해보자
이러한 상황을 개성하려면 결국 API의 추가적인 개선이 필요해 보인다. 다음과 같은 식으로 몇 가지 방향을 잡을 수 있을 것이다.
- Swift 네이티브 문자열을 위한 정규식 사용 API를 별도로 개발한다.
NSRange
와Range<String.Index>
를 브릿징한다.String
에서NSRange
를 사용할 수 있게 확장한다.
Swift만의 정규식 API를 새롭게 만들었으면 좋겠다는 의견도 웹상에서는 제법 보인다. 특히 루비에 대한 경험이 있는 개발자들이 루비의 정규식 패턴매칭 테스트 문법이 간결하다고 생각하는 것 같다. 하지만 기존 Foundation 코드와의 호환성을 유지하면서 문법적으로 보다 간단하고 자유로운 정규식 API를 디자인하기란 쉽지 않아보이며, 그런 이유로 아직까지 이에 대한 proposal은 보이지 않는다. (사실 기존 NSString
API로부터 간단한 매칭은 좀 더 쉽게 구현할 수 도 있고)
두번째로 NSRange
와 Range<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é?
-
Struct가 아닌 그냥 C 구조체 타입이며, 이 둘은 호환되는 관계가 아니다.
NSRect
의 경우에도 기존에는 C 구조체였으나, 워낙 자주 쓰이다 보니 이 경우는 아예 Struct로 재작성됐다. ↩ -
따라서
import Foundation
을 해주지 않으면 없는 없는 속성이라는 에러가 뜨게 된다. ↩ -
상호호환이 된다는 것은 API의 브릿징을 의미하는 것이다.
-substringWithRange:
의 예에서 보듯이 Swift 내에서NSString
을 사용하는 경우에 관련되는 함수나 메소드에서NSString
타입 대신에String
을 인자로 줄 수 있고, 또 리턴값으로 던져주도록 API가 변환되는 범위에서 호환을 말한다. 실질적인 두 타입간의as
연산자를 통한 캐스팅은 macOS/iOS 플랫폼에서만 가능하며, 리눅스 플랫폼의 Swift에서는 이 캐스팅을 지원하지 못한다. ↩ - https://developer.apple.com/documentation/swift/string/1642786-range ↩