掌握 Swift 的字串細節

洪朔-Tuccuay發表於2017-04-20

掌握 Swift 的字串細節

String 型別在任何程式語言中都是一個重要的組成部分。而使用者從 iOS 應用的螢幕上能讀取到最有效的資訊也來自文字。

為了觸及更多的使用者,iOS 應用必須國際化以支援大量現代語言。Unicode 標準解決了這個問題,不過這也給我們使用 string 型別帶來了額外的挑戰性。

從一方面來說,程式語言在處理字串時應該在 Unicode 複雜性和效能之間取得平衡。而另一方面,它需要為開發者提供一個舒適的結構來處理字串。

而在我看來,Swift 在這兩方面都做的不錯。

幸運的是 Swift 的 string 型別並不是像 JavaScript 或者 Java 那樣簡單的 UTF-16 序列。

對一個 UTF-16 碼單元序列執行 Unicode 感知的字串操作是很痛苦的:你可能會打破代理對或組合字元序列。

Swift 對此有著更好的實現方式。字串本身不再是集合,而是能夠根據不同情況為內容提供不同的 view。其中一個特殊的 view: String.CharacterView 則是完全支援 Unicode 的。

對於 let myStr = "Hello, world" 來說,你可以訪問到下面這些 view:

  • myStr.charactersString.CharacterView。可以獲取字形的值,視覺上呈現為單一的符號,是最常用的檢視。
  • myStr.unicodeScalarsString.UnicodeScalarView。可以獲取 21 整數表示的 Unicode 碼位。
  • myStr.utf16String.UTF16View。用於獲取 UTF16 編碼的程式碼單元。
  • myStr.utf8String.UTF8View。能夠獲取 UTF8 編碼的程式碼單元。

掌握 Swift 的字串細節
Swift 中的 CharacterView, UnicodeScalarView, UTF16View 和 UTF8View

在大多數時候開發者都在處理簡單的字串字元,而不是深入到編碼或者碼位這樣的細節中。

CharacterView 能很好地完成大多數任務:迭代字串、字元計數、驗證是否包含字串、通過索引訪問和比較操作等。

讓我們看看如何用 Swift 來完成這些任務。

1. Character 和 CharterView 的結構

String.CharacterView 的結構是一個字元內容的檢視,它是 Character 的集合。

要從字串訪問檢視,使用字元的 characters 屬性:

Try in Swift sandbox

let message = "Hello, world"
let characters = message.characters
print(type(of: characters))// => "CharacterView"複製程式碼

message.characters 返回了 CharacterView 結構.

字元檢視是 Character 結構的集合。例如,我們可以這樣來訪問字元檢視裡的第一個字元:

Try in Swift sandbox

let message = "Hello, world"
let firstCharacter = message.characters.first!
print(firstCharacter)           // => "H"
print(type(of: firstCharacter)) // => "Character"

let capitalHCharacter: Character = "H"
print(capitalHCharacter == firstCharacter) // => true複製程式碼

message.characters.first 返回了一個可選型別,內容是它的第一個字元 "H".

這個字元例項代表了單個符號 H

在 Unicode 標準中,H 代表 Latin Capital letter H (拉丁文大寫字母 H),碼位是 U+0048

讓我們掠過 ASCII 看看 Swift 如何處理更復雜的符號。這些字元被渲染成單個視覺符號,但實際上是由兩個或更多個 Unicode 標量 組成。嚴格來說這些字元被稱為 字形簇

重點CharacterView 是字串的字形簇集合。

讓我們看看 ç 的字形。他可以有兩種表現形式:

  • 使用 U+00E7 LATIN SMALL LETTER C WITH CEDILLA (拉丁文小寫變音字母 C):被渲染為 ç
  • 或者使用組合字元序列:U+0063LATIN SMALL LETTER C 加上 組合標記 U + 0327 COMBINING CEDILLA 組成複合字形:c + ◌̧ = ç

我們看看在第二個選項中 Swift 是如何處理它的:

Try in Swift sandbox

let message = "c\u{0327}a va bien" // => "ça va bien"
let firstCharacter = message.characters.first!
print(firstCharacter) // => "ç"

let combiningCharacter: Character = "c\u{0327}"
print(combiningCharacter == firstCharacter) // => true複製程式碼

firstCharacter 包含了一個字形 ç,它是由兩個 Unicode 標量 U+0063 and U+0327 組合渲染出來的。

Character 結構接受多個 Unicode 標量來建立一個單一的字形。如果你嘗試在單個 Character 中新增更多的字形,Swift 將會出發錯誤:

Try in Swift sandbox

let singleGrapheme: Character = "c\u{0327}\u{0301}" // Works
print(singleGrapheme) // => "ḉ"

let multipleGraphemes: Character = "ab" // Error!複製程式碼

即使 singleGrapheme 由 3 個 Unicode 標量組成,它建立了一個字形
multipleGraphemes 則是從兩個 Unicode 標量建立一個 Character,這將在單個 Character 結構中建立兩個分離的字母 ab,這不是被允許的操作。

2. 遍歷字串中的字元

CharacterView 集合遵循了 Sequence 協議。這將允許在 for-in 迴圈中遍歷字元檢視:

Try in Swift sandbox

let weather ="rain"for char in weather.characters {print(char)}// => "r" // => "a" // => "i" // => "n"複製程式碼

我們可以在 for-in 迴圈中訪問到 weather.characters 中的每個字元。char 變數將會在迭代中依次分配給 weather 中的 "r", "a", "i""n" 字元。

當然你也可以用 forEach(_:) 方法來迭代字元,指定一個閉包作為第一個引數:

Try in Swift sandbox

let weather = "rain"
for char in weather.characters {
  print(char)
}
// => "r"
// => "a"
// => "i"
// => "n"複製程式碼

使用 forEach(_:) 的方式與 for-in 相似,唯一的不同是你不能使用 continue 或者 break 語句。

要在迴圈中訪問當前字串的索引可以通過 CharacterView 提供的 enumerated() 方法。這個方法將會返回一個元組序列 (index, character)

Try in Swift sandbox

let weather = "rain"
for (index, char) in weather.characters.enumerated() {
  print("index: \(index), char: \(char)")
}
// => "index: 0, char: r"
// => "index: 1, char: a"
// => "index: 2, char: i"
// => "index: 3, char: n"複製程式碼

enumerated() 方法在每次迭代時返回元組 (index, char)
index 變數即為迴圈中當前字元的索引,而 char 變數則是迴圈中當前的字元。

3. 統計字元

只需要訪問 CharacterViewcount 屬性就可以獲得字串中字元的個數:

Try in Swift sandbox

let weather ="sunny"print(weather.characters.count)// => 5複製程式碼

weather.characters.count 是字串中字元的個數。

檢視中的每一個字元都擁有一個字形。當相鄰字元(比如 組合標記 )被新增到字串時,你可能發現 count 屬性沒有沒有變大。

這是因為相鄰字元並沒有在字串中建立一個新的字形,而是附加到了已經存在的 基本 Unicode 字形 中。讓我們看一個例子:

Try in Swift sandbox

var drink = "cafe"
print(drink.characters.count) // => 4
drink += "\u{0301}"
print(drink)                  // => "café"
print(drink.characters.count) // => 4複製程式碼

一開始 drink 含有四個字元。

當組合標記 U+0301COMBINING ACUTE ACCENT 被新增到字串中,它改變了上一個基本字元 e 並建立了新的字形 é。這時屬性 count 並沒有變大,因為字形數量仍然相同。

4. 按索引訪問字元

因為 Swift 直到它實際評估字元檢視中的字形之前都不知道字串中的字元個數,所以無法通過下標的方式訪問字串索引。

你可以通過特殊的型別 String.Index 訪問字元。

如果你需要訪問字串中的第一個或者最後一個字元,字元檢視結構提供了 firstlast 屬性:

Try in Swift sandbox

let season = "summer"
print(season.characters.first!) // => "s"
print(season.characters.last!)  // => "r"
let empty = ""
print(empty.characters.first == nil) // => true
print(empty.characters.last == nil)  // => true複製程式碼

注意 firstlast 屬性將會返回可選型別 Character?

在空字串 empty 這些屬性將會是 nil

掌握 Swift 的字串細節
String indexes in Swift

要獲取特定位置的字元,你必須使用 String.Index 型別(實際上是 String.CharacterView.Index的別名)。字元提供了一個接受 String.Index 下標訪問字元的方法,以及預定義的索引 myString.startIndexmyString.endIndex

讓我們使用字串索引來訪問第一個和最後一個字元:

Try in Swift sandbox

let color = "green"
let startIndex = color.startIndex
let beforeEndIndex = color.index(before: color.endIndex)
print(color[startIndex])     // => "g"
print(color[beforeEndIndex]) // => "n"複製程式碼

color.startIndex 是第一個字元的索引,所以 color[startIndex] 表示為 g
color.endIndex 表示結束位置,或者簡單的說是比最後一個有效下標引數大的位置。要訪問最後一個字元,你必須計算它的前一個索引:color.index(before: color.endIndex)

要通過偏移訪問字元的位置, 在 index(theIndex, offsetBy: theOffset) 方法中使用 offsetBy 引數:

Try in Swift sandbox

let color = "green"
let secondCharIndex = color.index(color.startIndex, offsetBy: 1)
let thirdCharIndex = color.index(color.startIndex, offsetBy: 2)
print(color[secondCharIndex]) // => "r"
print(color[thirdCharIndex])  // => "e"複製程式碼

指定 offsetBy 引數,你將可以放特定偏移量位置的字元。

當然,offsetBy 引數是的步進是字串的字形。即偏移量適用於 ChacterView 中的 Chacter 例項。

如果索引超出範圍,Swift 會觸發錯誤。

Try in Swift sandbox

let color ="green"
let oops = color.index(color.startIndex, offsetBy:100) // Error!複製程式碼

為了防止這種情況,可以指定一個 limitedBy 引數來限制最大偏移量:index(theIndex, offsetBy: theOffset, limitedBy: theLimit)。這個函式將會返回一個可選型別,當索引超出範圍時將會返回 nil

Try in Swift sandbox

let color = "green"
let oops = color.index(color.startIndex, offsetBy: 100,
   limitedBy: color.endIndex)
if let charIndex = oops {
  print("Correct index")
} else {
  print("Incorrect index")
}
// => "Incorrect index"複製程式碼

oops 是一個可選型別 String.Index?。展開可選型別可以驗證索引是否超出了字串的範圍。

5. 檢查子串是否存在

驗證子串是否存在的最簡單方法是呼叫 contains(_ other: String) 方法:

Try in Swift sandbox

import Foundation
let animal = "white rabbit"
print(animal.contains("rabbit")) // => true
print(animal.contains("cat")) // => false複製程式碼

animal.contains("rabbit") 將返回 true 因為 animal 包含了 "rabbit" 字串。

那麼當子字串不存在的時候 animal.contains("cat") 的值將為 false

要驗證字串是否具有特定的字首或字尾,可以使用 hasPrefix(_:)hasSuffix(_:) 方法。我們來看一個例子:

Try in Swift sandbox

importFoundationlet
animal = "white rabbit"
print(animal.hasPrefix("white")) // => true
print(animal.hasSuffix("rabbit")) // => true複製程式碼

"white rabbit""white" 開頭並以 "rabbit" 結尾。所以我們呼叫 animal.hasPrefix("white")animal.hasSuffix("rabbit") 方法都將返回 true

當你想搜尋字串時,直接查詢字元檢視是就可以了。比如:

Try in Swift sandbox

let animal = "white rabbit"
let aChar: Character = "a"
let bChar: Character = "b"
print(animal.characters.contains(aChar)) // => true
print(animal.characters.contains {
  $0 == aChar || $0 == bChar
}) // => true複製程式碼

contains(_:) 將驗證字元檢視是否包含指定檢視。

而第二個函式 contains(where predicate: (Character) -> Bool) 則是接受一個閉包並執行驗證。

6. 字串操作

字串在 Swift 中是 value type(值型別)。無論你是將它作為引數進行函式呼叫還是將它分配給一個變數或者常量——每次複製都將會建立一個全新的拷貝

所有的可變方法都是在空間內將字串改變。

本節涵蓋了對字串的常見操作。

附加字串到另一個字串

附加字串較為簡便的方法是直接使用 += 操作符。你可以直接將整個字串附加到原始字串:

Try in Swift sandbox

var bird ="pigeon"
bird +=" sparrow"
print(bird) // => "pigeon sparrow"複製程式碼

字串結構提供了一個可變方法 append()。該方法接受字串、字元甚至字元序列,並將其附加到原始字串。例如

Try in Swift sandbox

var bird = "pigeon"
let sChar: Character = "s"
bird.append(sChar)
print(bird) // => "pigeons"
bird.append(" and sparrows")
print(bird) // => "pigeons and sparrows"
bird.append(contentsOf: " fly".characters)
print(bird) // => "pigeons and sparrows fly"複製程式碼

從字串中擷取字串

使用 substring() 方法可以擷取字串:

  • 從特定索引到字串的末尾
  • 從開頭到特定索引
  • 或者基於一個索引區間

讓我們來看看它是如何工作的

Try in Swift sandbox

let plant = "red flower"
let strIndex = plant.index(plant.startIndex, offsetBy: 4)
print(plant.substring(from: strIndex)) // => "flower"
print(plant.substring(to: strIndex))   // => "red "

if let index = plant.characters.index(of: "f") {
  let flowerRange = index..<plant.endIndex
  print(plant.substring(with: flowerRange)) // => "flower"
}複製程式碼

字串下標接受一個區間或者封閉區間作為字元索引。這有助於根據範圍擷取子串:

Try in Swift sandbox (target=undefined)

let plant ="green tree"let excludeFirstRange =
  plant.index(plant.startIndex, offsetBy:1)..<plant.endIndex
print(plant[excludeFirstRange]) // => "reen tree"
let lastTwoRange = plant.index(plant.endIndex, offsetBy:-2)..<plant.endIndex
print(plant[lastTwoRange]) // => "ee"複製程式碼

插入字串

字串型別提供了可變方法 insert()。此方法可以在特定索引處插入一個字元或者一個字元序列。

新的字元將被插入到指定索引的元素之前。

來看一個例子:

Try in Swift sandbox

var plant = "green tree"
plant.insert("s", at: plant.endIndex)
print(plant) // => "green trees"
plant.insert(contentsOf: "nice ".characters, at: plant.startIndex)
print(plant) // => "nice green trees"複製程式碼

移除字元

可變方法 remove(at:) 可以刪除指定索引處的字元:

Try in Swift sandbox

var weather = "sunny day"
if let index = weather.characters.index(of: " ") {
  weather.remove(at: index)
  print(weather) // => "sunnyday"
}複製程式碼

你也可以使用 removeSubrange(_:) 來從字串中移除一個索引區間內的全部字元:

Try in Swift sandbox

var weather = "sunny day"
let index = weather.index(weather.startIndex, offsetBy: 6)
let range = index..<weather.endIndex
weather.removeSubrange(range)
print(weather) // => "sunny"複製程式碼

替換字串

replaceSubrange(_:with:) 方法接受一個索引區間並可以將區間內的字串替換為特定字串。這是字串的一個可變方法。

一個簡單的例子:

Try in Swift sandbox

var weather = "sunny day"
if let index = weather.characters.index(of: " ") {
  let range = weather.startIndex..<index
  weather.replaceSubrange(range, with: "rainy")
  print(weather) // => "rainy day"
}複製程式碼

另一些關於字串的可變操作

上面描述的許多字串操作都是直接應用於字串中的字元檢視。

如果你覺得直接對字元序列進行操作更加方便的話,那也是個不錯的選擇。

比如你可以刪除特定索引出的字元,或者直接刪除第一個或者最後一個字元:

Try in Swift sandbox

var fruit = "apple"
fruit.characters.remove(at: fruit.startIndex)
print(fruit) // => "pple"
fruit.characters.removeFirst()
print(fruit) // => "ple"
fruit.characters.removeLast()
print(fruit) // => "pl"複製程式碼

使用字元檢視中的 reversed() 方法來翻轉字元檢視:

Try in Swift sandbox

var fruit ="peach"
var reversed =String(fruit.characters.reversed())
print(reversed)// => "hcaep"複製程式碼

你可以很簡單得過濾字串:

Try in Swift sandbox

let fruit = "or*an*ge"
let filtered = fruit.characters.filter { char in
  return char != "*"
}
print(String(filtered)) // => "orange"複製程式碼

Map 可以接受一個閉包來對字串進行變換:

Try in Swift sandbox

let fruit = "or*an*ge"
let mapped = fruit.characters.map { char -> Character in
  if char == "*" {
      return "+"
  }
  return char
}
print(String(mapped)) // => "or+an+ge"複製程式碼

或者使用 reduce 來對字串來進行一些累加操作:

Try in Swift sandbox

let fruit = "or*an*ge"
let numberOfStars = fruit.characters.reduce(0) { countStars, char in
    if (char == "*") {
        return countStarts + 1
    }
    return countStars
}
print(numberOfStars) // => 2複製程式碼

7. 說在最後

首先要說,大家對於字串內容持有的不同觀點看起來似乎過於複雜。

而在我看來這是一個很好的實現。字串可以從不同的角度來看待:作為字形集合、UTF-8 / UTF-16 碼位或者簡單的 Unicode 標量。

根據你的任務來選擇合適的檢視。在大多數情況下,CharacterView 都很合適。

因為字元檢視中可能包含來自一個或多個 Unicode 標量組成的字形。因此字串並不能像陣列那樣直接被整數索引。不過可以用特殊的 String.Index 來索引字串。

雖然特殊的索引型別導致在訪問單個字串或者操作字串時增加了一些難度。我接受這個成本,因為在字串上進行真正的 Unicode 感知操作真的很棒!

對於字元操作你有沒有找到更舒適的方法?寫下評論我們一起來討論一些吧!

P.S. 不知道你有沒有興趣閱讀我的另一篇文章:detailed overview of array and dictionary literals in Swift

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章