카테고리 보관물: 스터디

(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라는 블럭으로 등재되어 있다. 

스크립트에서 파일명 확장 (vim)

vim 명령줄 모드에서 %는 보통 현재 파일의 전체 범위1현재 파일 이름의 의미가 된다.

그렇다면 함수와 같은 스크립트 문맥에서는 어떨까? 스크립트 문맥에서는 파일명확장이 이루어지지 않고, 대신에 expand()함수를 써서 수동으로 처리해야 한다.

let current_file_name = expand('%')
let current_file_prefix = expand('%<')

그외 몇 가지 옵션이 있는데 이는 :h expand()로 찾아보면 된다.

  • % : 현재 파일이름
  • # : 대체 이름 (아마 이전 파일?)
  • #n : 대체이름 (n번째 이전 파일)
  • <cfile>: 커서가 있는 위치의 파일 이름
  • <cword> : 커서가 있는 위치의 단어
  • <cWORD> : 커서가 있는 위치의 단어를 대문자로

그외에 확장자가 붙어서 이를 변환할 수 있다. 그 중 일부만 소개하면 아래와 같다. (전체 목록은 도움말 내용을 확인하자.)

  • :p : full path로 확장한다.
  • :h : 헤드 (마지막 패스 요소를 제거한다.)
  • :t: 꼬리 (첫 패스 요소를 제거한다.)

  1. %s/ ... // 등에서 범위로 사용될 때는 전체 범위를 의미한다.