NSScanner

NSScanner

http://nshipster.com/nsscanner/

문자열은 모든 곳에 있고 컴퓨팅의 여러 부분을 담당하고 있다. 이메일, 에세이, 시, 소설, 이 글도 그렇고 심지어 블로그의 코드도 모두 문자열로 되어 있다. 문자열을 쪼개고 특정한 부분을 추출해 내는 능력은 프로그래밍에서 강력한 무기가 될 수 있다. 코코아는 문자열을 쪼개고 합치는 다양한 방법을 제공하는데, 그 중 몇 가지 유명한 예를 들자면,

  • string.componentsSeparatedByCharactersInSet / string.componentsSeparatedByString : 문자열을 특정 토큰을 기준으로 잘라 배열로 쪼갠다.
  • NSRegularExpression : 정규식을 적용한다. 하지만 정규식은 복잡한 문자열에 대해서는 꽤나 번거롭기도 하거니와 많은 주의를 기울일 필요가 있다.
  • NSDataScanner : 문자열에서 주소, 날짜와 시간, URL 등을 추출해 내지만 정해진 포맷에 대해서만 사용할 수 있다는 제약이 있다.
  • NSScanner : 고도로 커스터마징이 가능한 문자열 스캔 클래스.

NSScanner는 문자열의 래퍼처럼 사용하고, 내부의 문자열을 탐색해서 효과적으로 부분문자열 집합이나 숫자값등을 추출해 낼 수 있다. 이 동작을 제어하는 몇 가지 프로퍼티를 살펴보자면,

  • caseInsensitive:Bool : 문자열을 탐색할 때 대소문자 구분을 무시할 것인지 여부
  • charactersToBeSkipped:NSCharacterSet : 탐색시 무시할 글자들을 정의한다.
  • scanLocation:Int : 스캐너의 현재 위치이다. 이 값을 조정하면 빨리감기/되감기 등을 할 수 있다.
  • locale:NSLocale : 문자열을 숫자값으로 바꿔낼 때 사용할 로케일 값이다.

숫자값을 추출하기

스캐너 객체의 존재의 이유는 큰 문자열로부터 특정한 데이터를 검색하여 뽑아내는데 있다. 이를 위한 열 개가 넘는 메소드가 존재하는데, 이들은 공통된 패턴을 가진다. 즉 찾은 값을 반환할 포인터를 인자로 받고, 성공 여부를 리턴한다.

다음 예를 살펴보자.

var name: NSString?

let skip = NSMutableCharacterSet.whitespaceAndNewlineCharacterSet()
skip.formUnionWithCharacterSet(NSCharacterSet.punctuationCharacterSet())

let stringScanner = NSScanner(string:"John & Paul & Ringo & George.")
stringScanner.charactersToBeSkipped = skip

while stringScanner.scanUpToCharactersFromSet(
                        skip,
                        intoString: &name){
    if let _name = name {
        println(_name)      
    }
}

(ObjC)

NSMutableCharacterSet *skip = [NSMutableCharacterSet punctuationCharacterSet];
[skip formUnionWithCharacterSet:[NSCharacterSet whitesapceAndNewlineCharacterSet]];
NSScanner *stringScanner = [[NSScanner alloc] initWithString:@"John & Paul & Ringo & George."];

NSString *name;
while ([stringScanner scanUptoCharactersFromSet:skip intoString:&name]) {
    NSLog(@"%@", name);0
}

문자열 스캐너는 다음과 같은 동작을 수행할 수 있는데, 기본적으로 문자열을 탐색할 수 있다.

  • scanString:intoString: / scanCharactersFromSet:intoString: : 특정 문자열이나 문자셋과 매치되는지를 보기 위해서 탐색한다. 이 때 intoString은 결과를 돌려받기 위한 포인터이다. 종종 결과가 필요없거나 스캐너의 스캔위치만을 이동시키는 경우에 nil을 전달할 때도 있다.

  • scanUpToString:intoString: / scanUptoCharactersFromSet:intoString: : 특정 문자열까지, 특정 문자셋까지 탐색한다. 만약 스캐너는 탐색하면서 그 결과를 계속 밀어넣으므로, 탐색에 실패하는 경우에는 intoString에는 탐색을 시작한 위치부터의 문자열의 나머지끝이 들어간다.

숫자도 탐색 가능하다. 숫자 포맷에 따라서 비슷한 원리로 동작되는 메소드들을 가진다.

  • scanDouble: / scanFloat: / scanDecimal: : 각각 참조로 넘겨받는 double, float, NSDecial 에 대해 값을 넣어준다.
  • scanInteger: / scanInt: / scanLongLong / scanUnsignedLongLong: : 정수값을 읽어낸다.
  • scanHexDouble: / scanHexFloat: / scanHexInt: / scanHexLongLong: 16진수로된 글자와 숫자들을 읽어서 이를 변환해준다.

문자열 지역화와 NSScanner

NSScanner도 코코아 프레임워크의 일부이고, 이 프레임워크는 지역화를 지원하므로, NSScanner도 지역화를 지원할 수 있다. 이는 지역화된 스캐너를 생성하는 +localizedScannerWithString: 을 통해서 생성하거나 locale 프로퍼티를 설정하는 것으로 만들 수 있다.

double price;
NSScanner *gasPriceScanner = [[NSScanner alloc] initWithString:@"2.09 per callon"];
[gasPriceScanner scanDouble:&price];
// price --> 2.09

NSScanner *benzinPriceScanner = [[NSScanner alloc] initWithString:@"1,38 pro Liter"];
[benzinPriceScanner setLocale:[NSLocale localeWithLocaleIdentifier:@"de-DE"]];
[benzinPriceScanner scanDouble:&price];

예제, SVG 데이터 파싱하기

NSScanner를 활용할 수 있는 예로 SVG데이터를 예로 들어보겠다. SVG데이터는 각 좌표에서 다음 좌표로 이어지는 방법을 알리는 접두어(M, L, C)와 각 점의 좌표의 연속으로 이루어지며, 각 숫자값의 구분은 기본적으로 컴마이긴한데, 뒤의 숫자가 음수인 경우 컴마가 생략되기도 한다.

var svgPathData = "M28.2,971.4c-10,0.5-19.1,13.3-28.2,2.1c0,15.1,23.7,30.5,39.8,16.3c16,14.1,39.8-1.3,39.8-16.3c-12.5,15.4-25-14.4-39.8,4.5C35.8,972.7,31.9,971.2,28.2,971.4z"

좌표의 이동을 나타내기 위해 offset() 메소드를 CGPoint에 확장해보겠다.

extension CGPoint {
    func offset(p:CGPoint) -> CGPoint {
        return CGPoint(x: x+p.x, y:y+p.y)
    }
}

이 데이터의 규칙성을 찾기가 힘들다. x, y 좌표값은 주로 컴마로 구분되지만 간혹 그렇지 않은 경우도 있다. 이를 정규식으로 구분하는 것은 꽤 어려울 수 있다. 대신에 NSScanner를 사용하는 것은 조금 더 쉬울 수 있다. 아래 함수는 이런 데이터를 파싱하여 UIBezierPath를 생성할 수 있도록 한다.

func bezierPathFromSVGPath(str:String) -> UIBezierPath {
    let scanner = NSScanner(string: str)

    // 콤마랑 공백문자는 패스하도록 설정한다.
    let skipChars = NSMutableCharacterSet(charactersInString:",")
    skipChars.formUnionWithCharactersSet(NSCharacterSet.whitespaceAndNewlineCharacterSet())
    scanner.charactersToBeSkipped = skipChars


    var path = UIBesizerPath()


    let instructionSet = NSCharacterSet(charactersInString:"MCSQTAmcsqta") //데이터에 들어있을 컨트롤 문자들
    var instruction:NSString?

    while scanner.scanCharactersFromSet(instructionSet, intoString:&instruction) { // 먼저 컨트롤 문자를 뽑아내고,
        var x = 0.0, y = 0.0
        var points:[CGPoint] = []

        while scanner.scanDouble(&x) && scanner.scanDouble(&y) { //좌표값이 될 숫자 두 개를 뽑는다.
            points.append(CGPoint(x:x, y:y))
        }

        switch instruction ?? "" { // ?? 는 옵셔널 값이 nil 이면 뒤에 오는 값을, 아니면 해당 값을 언래핑한다.
        case "M":
            path.moveToPoint(point[0]) // M 인 경우, 선을 그리지 않고 해당 포인트로 이동
        case "C":
        // C인 경우 커브포인트이므로, 도착지점과 두 개의 컨트롤 포인트가 된다.
            path.addCurveToPoint(Points[2], controlPoint1: points[0], controlPoint2: points[1]) 
        case "c": // c 인 경우, 상대좌표를 이용하여 포인트 및 컨트롤 포인트를 만들어서 추가한다.
            path.addCurveToPoint(path.currentPoint.offset(points[2]),
                controlPoint1: path.currentPoint.offset(points[0]),
                controlPoint2: path.currentPoint.offset(points[1])
            )
        default:
            break
        }
    }
    return path;
}

아래 gist 코드는 간단하게 각 데이터 포맷을 읽어서 그 값을 옵셔널로 리턴하거나 nil을 리턴하도록 하는 확장이다.

이 확장을 이용하면 위 while 문을 좀 더 swifty 하게 변경할 수 있다.

while let instruction = scanner.scanCharactersFromSet(instructionSet) {
    var points:[CGPoint] = []

    while let x = scanner.scanDouble(), y = scanner.scanDouble() {
        points.append(CGPoint(x:x, y:y))
    }
    ...
}