Swift 进阶_字符串

1,267 阅读13分钟

字符串

Swift 中的 String 是 Character 值的集合,而 Character 是人类在阅读文字时所理解的单个字符,这与该字符由多少个 Unicode 标量组成无关。

Swift里面的String不支持随机访问,不能用类似str[999]来获取字符串的第一千个字符。

当字符拥有可变宽度时,字符串并不知道第 n 个字符到底存储在哪儿,它必须查看这个字符前面的所有字符,才能最终确定对象字符的存储位置,所以这不可能是一个O(1)操作

Unicode,而非固定宽度

ASCII字符串就是由0到127之间的整数组成的序列。可以把这种整数放到一个8比特的字节里。

但是8个比特对于许多语言的编码来说是不够用的。

当固定宽度的编码空间被用完后,有两种选择:

  • 增加宽度(当初被定义成2个字节固定宽度的格式,2个字节不够用,4个字节又太低效)
  • 切换到可变长的编码(最终选择这种可变长格式)

一些Unicode名词关系

  • Swift里说的“单个字符” = Swift里说的1个Character = 1个字位簇
  • 1个Unicode字符 = 1个字位簇 = 1个或多个Unicode标量
  • 1个Unicode标量 可编码成 1个或多个编码单元
  • 1个Unicode标量 大多数情况下可理解成 1个编码点

编码点

  • Unicode中最基础的原件叫做编码点,编码点是一个位于Unicode编码空间 (从0到0x10FFFF,也就是十进制的 1,114,111) 中的整数。
  • Unicode中的每个字符或其它语系单位或颜文字(emoji)都有1个唯一的编码点。
  • 编码点都会写成带有U+前缀的十六进制数,比如欧元符号 -> U+20AC,在Swift里 -> "\u{20AC}" = "€"

编码单元

  • Unicode数据,可以用多种不同的编码方式进行编码,其中最普遍使用的是8比特(UTF-8)和16比特(UTF-16)。
  • 编码方式中使用的最小实体叫做编码单元,也就是说UTF-8编码单元的宽度是8比特,而UTF-16编码单元的宽度是16比特。
  • UTF-8 提供的一个额外的好处就是为使用8比特的ASCII编码提供了向后兼容,正是这个特性,才让 UTF-8接过了ASCII大旗,成为了现如今Web和文件格式中最为流行的编码方式。
  • Swift里,UTF-8和UTF-16使用的编码单元的值分别用UInt8和UInt16表示 (它们还有两个别名,分别是Unicode.UTF8.CodeUnit和Unicode.UTF16.CodeUnit)

字位簇和标准等价

合并标记

String

let single = "Pok\u{00E9}mon" // Pokémon 
let double = "Poke\u{0301}mon" // Pokémon

(single, double) // ("Pokémon", "Pokémon")
single.count // 7 
double.count // 7

//默认就是按照标准等价的方式进行比较
single == double // true

//通过比较组成字符串的Unicode标量
single.unicodeScalars.count // 7 
double.unicodeScalars.count // 8

//通过比较字符串的utf8
single.utf8.elementsEqual(double.utf8) // false
let chars: [Character] = [ "\u{1ECD}\u{300}", // ọ́
 "\u{F2}\u{323}", // ọ́
 "\u{6F}\u{323}\u{300}", // ọ́
 "\u{6F}\u{300}\u{323}" // ọ́ ]
let allEqual = chars.dropFirst().allSatisfy { $0 == chars.first } // true

NSString

let nssingle = single as NSString 
nssingle.length // 7 

let nsdouble = double as NSString 
nsdouble.length // 8 

nssingle == nsdouble // false

//按照标准等价的方式进行比较两个 NSString,就得使用 NSString.compare(_:) 方法

颜文字

Java或者C#里,会认为"😂"是两个“字符”长,Swift 则能正确处理这种情况:

let oneEmoji = "😂"// U+1F602
oneEmoji.count // 1

这里重要的是,字符串是如何呈现在程序中的,而不是它是如何存储在内存中的。

有些颜文字还可以由多个Unicode标量组合而成:

let flags = "🇧🇷🇳🇿"
flags.count // 2

要观察组成字符串的 Unicode 标量,我们可以使用字符串的 unicodeScalars 视图,这里,我 们将标量值格式化为编码点常用的十六进制格式:

fags.unicodeScalars.map { 
    "U+\(String($0.value, radix: 16, uppercase: true))" 
}
// ["U+1F1E7", "U+1F1F7", "U+1F1F3", "U+1F1FF"]

把五种肤色修饰符 (比如 🏽,或者其他四种肤色修饰符之一) 和一个像是👧的基础角色组合起来,就可以得到类似👧🏽这样的带有肤色的角色。再一次,Swift 能正确对其处理:

let skinTone = "👧🏽" // 👧 + 🏽
skinTone.count // 1

对于这种表达人群的颜文字,无论是性别还是人数都存在着数不清的组合,为其中每一种组合都单独定义一个编码点非常容易出问题。如果再把这些组合考虑上肤色的维度,让每种情况都有对应的编码点简直就成了一件不可能的事情。

对此,Unicode 的解决方案是把这种复杂的颜 文字表示成一个简单颜文字的序列,序列中的颜文字则通过一个标量值为 U+200D 的不可见零宽连接字符 (zero-width joiner,ZWJ) 连接

ZWJ 的存在,是对操作系统的提示,表明 如果可能的话,把 ZWJ 连接的字符当成一个字形符号 (glyph) 处理。

let family1 = "👨‍👩‍👧‍👦"
let family2 = "👨\u{200D}👩\u{200D}👧\u{200D}👦"
family1 == family2 // true

family1.count // 1 
family2.count // 1

字符串和集合

String 是 Character 值的集合

Swift4以后:将两个集合连接的时候,你可能会假设所得到的集合的长度是两个用来连接的集合长度之和。但是对于字符串来说,如果第一个集合的末尾和第二个集合的开头能够形成一个字位簇的话,它们就不再相等。

let flagLetterC = "🇨"
let flagLetterN = "🇳"
let flag = flagLetterC + flagLetterN // 🇨🇳
flag.count // 1
flag.count == flagLetterC.count + flagLetterN.count // false

双向索引,而非随机访问

String 并不是一个可以随机访问的集合。就算知道给定字符串中第 n 个字符的位置,也并不会对计算这个字符之前有多少个 Unicode 标量有任何帮助。String 只实现了 BidirectionalCollection,你可以从字符串的头或者尾开始,向后或者向前移动,代码会察看毗邻字符的组合,跳过正确的字节数。不管怎样,你每次只能迭代一个字符。

当你在书写一些字符串处理的代码时,需要将这个性能影响时刻牢记在心。那些需要随机访问 才能维持其性能保证的算法对于Unicode字符串来说并不是一个好的选择

prefix 总是要从头开始工作,然后在字符串上经过所需要的字符个数,在一个线性复杂度的处理中运行另一个线性复杂度的操作,意味着算法复杂度将会是 O(n^2)。

extension String { 
    var allPrefixes1: [Substring] { 
        return (0...count).map(prefix)
     } 
 }
let hello = "Hello" 
hello.allPrefixes1 // ["", "H", "He", "Hel", "Hell", "Hello"]

需要迭代一次字符串,以获取索引的集合indices,map中的下标操作就是O(1)复杂度的,这使得整个算法的复杂度得以保持在 O(n)。

extension String { 
    var allPrefixes2: [Substring] { 
        return [""] + indices.map { index in self[...index] } 
    } 
}
let hello = "Hello" 
hello.allPrefixes2 // ["", "H", "He", "Hel", "Hell", "Hello"]

范围可替换,而非可变

String 还满足 RangeReplaceableCollection 协议

首先找到字符串索引中一个恰当的范围,然后通过调用 replaceSubrange 来完成字符串替换

var greeting = "Hello, world!" 
if let comma = greeting.index(of: ",") { 
    greeting[..<comma] // Hello 
    greeting.replaceSubrange(comma..., with: " again.") 
}
greeting // Hello again.

和之前一样,要注意用于替换的字符串有可能与原字符串相邻的字符形成新的字位簇。

字符串索引

String的索引类型是String.Index,本质上是一个存储了从字符串开头的字节偏移量的不透明值。

计算第 n 个字符所对应的索引 -> 花费 O(n) 的时间

通过索引下标访问字符串 -> 花费 O(1) 的时间

操作字符串索引的 API 与你在遇到其他任何集合时使用的索引操作是一样的,它们都基于 Collection 协议。

index(after:)

let s = "abcdef" 
let second = s.index(after: s.startIndex) 
s[second] // b

index(_:offsetBy:)

// 步进 4 个字符 
let sixth = s.index(second, offsetBy: 4) 
s[sixth] // f

limitedBy: 参数

let safeIdx = s.index(s.startIndex, offsetBy: 400, limitedBy: s.endIndex)
safeIdx // nil

有些简单的需求,使用索引,看起来都比较麻烦:

s[..<s.index(s.startIndex, offsetBy: 4)] // abcd

但是可以通过 Collection 的接口来访问字符串

s.prefx(4) // abcd
let date = "2019-09-01" 
date.split(separator: "-")[1] // 09 
date.dropFirst(5).prefx(2) // 09
var hello = "Hello!" 
if let idx = hello.frstIndex(of: "!") { 
    hello.insert(contentsOf: ", world", at: idx) 
}
hello // Hello, world!

有一些字符串操作的任务是无法通过 Collection API 完成的,比如解析 CSV 文件:

func parse(csv: String) -> [[String]] { 
    var result: [[String]] = [[]] 
    var currentField = "" 
    var inQuotes = false
    
    for c in csv { 
        switch (c, inQuotes) { 
        case (",", false): 
            result[result.endIndex-1].append(currentField)
            currentField.removeAll()
        case ("\n", false): 
            result[result.endIndex-1].append(currentField)
            currentField.removeAll() 
            result.append([]) 
        case ("\"", _): 
            inQuotes = !inQuotes 
        default:
            currentField.append(c)
        } 
     } 
     result[result.endIndex-1].append(currentField)
     return result 
}
//字符串用 ## 包围起来。这样就可以在字符串中直接使用引号而不用转义了
let csv = #""" 
"Values in quotes","can contain , characters" 
"Values without quotes work as well:",42 
"""# 

parse(csv: csv)
/*
[["Values in quotes", "can contain , characters"], ["Values without quotes work as well:", "42"]] 
*/

结构精简,无需跟踪很多内部状态,仅用一个布尔变量,通过一点额外的工作,我们还可以忽略空 行、忽略引号周围的空格,并支持在字段的引号中通过转义的方式继续使用引号。

子字符串(Substring)

以原始字符串内容为基础,用不同起始和结束位置标记的视图。

  • 子字符串和原字符串共享文本存储,好处:对字符串切片成为了非常高效的操作。
let sentence = "The quick brown fox jumped over the lazy dog." 
let frstSpace = sentence.index(of: " ") ?? sentence.endIndex 
let frstWord = sentence[..<frstSpace] // The 
type(of: frstWord) // Substring
//创建 firstWord 并不会导致昂贵的复制操作或者内存申请
  • split(字符串分割),它会返回一个[Substring]
let poem = """ 
Over the wintry 
forest, winds howl in rage 
with no leaves to blow. 
""" 
let lines = poem.split(separator: "\n")
lines// ["Over the wintry", "forest, winds howl in rage", "with no leaves to blow."] 
type(of: lines) // Array<Substring>

//整个过程中没有发生对输入字符串的复制
  • split接受闭包作为参数。
extension String {
    func wrapped(after maxLength: Int = 70) -> String {
        var lineLength = 0
        let lines = self.split(omittingEmptySubsequences: false) { character in
            if character.isWhitespace && lineLength >= maxLength {
                lineLength = 0
                return true
            } else {
                lineLength += 1
                return false
            }
        }
        return lines.joined(separator: "\n")
    }
}

let sentence = "The quick brown fox jumped over the lazy dog." 
sentence.wrapped(after: 15)
/*
The quick brown
fox jumped over
the lazy dog.
*/
  • split接受含有多个分隔符的序列作为参数。
extension Collection where Element: Equatable {
    func split<S: Sequence>(separators: S) -> [SubSequence]
    where Element == S.Element {
        return split { separators.contains($0) }
    }
}

"Hello, world!".split(separators: ",! ") // ["Hello", "world"]

StringProtocol

  • Substring 和 String 的接口几乎完全一样,因为他们都遵循StringProtocol协议。

  • 几乎所有的字符串 API 都被定义在StringProtocol 上,对于 Substring,你完全可以假装将它看作就是一个 String。

  • 和所有的切片一样,Substring的设计意图是用于短期存储,以避免在操作过程中发生昂贵的复制。

  • 当这个操作结束,应该通过初始化方法从 Substring 创建一个新的 String。不鼓励长期存储子字符串的根本原因在于,子字符串会一直持有整个原始字符串,造成内存泄漏。

func lastWord(in input: String) -> String? { 
    // 处理输⼊,操作⼦字符串 
    let words = input.split(separators: [",", " "]) 
    guard let lastWord = words.last else { return nil } 
    // 转换为字符串并返回
    return String(lastWord) 
}

lastWord(in: "one, two, three, four, five") // Optional("five")
  • 多数函数接受String类型或者StringProtocol,很少接受Substring类型的,如果需要传递 Substring,可以这么做:
// 使⽤原字符串开头索引和结尾索引作为范围的⼦字符串 
let substring = sentence[...]
  • Swift不建议将你的所有 API 从接受 String 实例转换为遵守 StringProtocol 的类型,建议是坚持使用 String。

    • 泛型本身也会带来开销。
    • String会更加简单和清晰。
    • 用户在有限的几个场合对 String 进行转换,也不会带来太大的负担。
  • 如果你想要扩展 String 为其添加新的功能,将这个扩展放在 StringProtocol 会是一个好主意,这可以保持 String 和 Substring API 的统一性。StringProtocol 设计之初就是为了在你想要对String 扩展时来使用的。如果你想要将已有的扩展从 String 移动到 StringProtocol 的话,唯一需要做的改动是将传入其他 API 的 self 通过 String(self) 换为具体的 String 类型实例。

  • 不要声明任何新的遵守 StringProtocol 协议的类型。只有标准库中的 String 和 Substring 是有效的适配类型。

编码单元视图

有时候Character字符无法满足需要时,我们还可以向下到比如Unicode标量或者编码单元这样更低的层次中进行查看和操作。

  • String 为此提供了三种视图:unicodeScalarsutf16utf8

  • 为什么你会想要对某个视图进行访问和操作?

    • 在一个 UTF-8 编码的网页中进行渲染。
    • 和某个只接受某种特定编码的非 Swift API 进行交互。
    • 你需要字符串某种特定格式下的信息等。
    • 相比于操作完整的字符来说,对编码单元进行操作会更快一些
  • Twitter以前的字符计算算法是基于NFC归一化标量:

    let tweet = "☕️e\u{301}🇫🇷☀️"
    print(tweet.count) // 1+1+1+1=4
    
    var characterCount = tweet.unicodeScalars.count
    print(characterCount) //2+2+2+2=8
    
    characterCount = tweet.precomposedStringWithCanonicalMapping.unicodeScalars.count
    print(characterCount) //2+1+2+2=7
    
    //precomposedStringWithCanonicalMapping: 按照C标准进行字符串归一化
    //NFC 归一可以对基础字母及合并标记进行转换,比如 "cafe\u{301}" 中的 e 和变音符可以被正
    确预组起来。
    
  • UTF-8 是用来存储或者在网络上发送文本的事实标准。因为 utf8 视图是一个集合,你可以用它来将字符串的 UTF-8 字节传递给任一接受一串字节的其他 API,例如 Data 或者 Array 的初始化方法:

    let tweet = "☕️e\u{301}🇫🇷☀️"
    let utf8Bytes = Data(tweet.utf8)
    print(utf8Bytes.count) // 6+3+8+6=23
    
  • UTF-8 是 String 所有编码单元视图中,系统开销最低的。因为它是 Swift 字符串在内存中的原生存储格式。

  • utf8 集合不包含字符串尾部的 null 字节。如果你需要用 null 表示结尾的话,可以使用 String 的 withCString 方法或者 utf8CString 属性。后者会返回一个字节的数组。

    let tweet = "☕️e\u{301}🇫🇷☀️"
    
    let withCStringCount = tweet.withCString { _ in strlen(tweet) }
    print(withCStringCount) // 23
    
    let nullTerminatedUTF8 = tweet.utf8CString
    print(nullTerminatedUTF8.count) // 24
    
  • 这些视图都没有提供我们想要的随机访问特性。这样造成的后果是,那些要求随机访问的算法将不能很好地运行在 String 和它的视图上。

  • 如果你真的需要随机存储的话,你依然可以把字符串自身或着它的视图转换为数组,例如:Array(str) 或 Array(str.utf8),然后对它们进行操作。

共享索引

  • 字符串和它们的视图共享同样的索引类型,String.Index。可以从字符串中获取一个索引,然后将它用在某个视图的下标访问中。
let pokemon = "Poke\u{301}mon" // Pokémon 
if let index = pokemon.index(of: "é") { 
    let scalar = pokemon.unicodeScalars[index] // e 
    String(scalar) // e
}
  • 只要是你从上往下进行,也就是在从字符,到标量,再到 UTF-16UTF-8 编码单元这个方向上的话,这么做不会有什么问题。但是另一个方向的话就不一定正确了,因为并不是每个编码单元视图中的有效索引都会在 Character 的边界上。
let family = "👨‍👩‍👧‍👦"
let someUTF16Index = String.Index(utf16Offset: 2, in: family)
family[someUTF16Index] //Crash
  • samePosition(in:)输入的索引在给定的视图中没有对应的位置,将返回 nil
let pokemon = "Poke\u{301}mon" // Pokémon
if let accentIndex = pokemon.unicodeScalars.firstIndex(of: "\u{301}") { 
    accentIndex.samePosition(in: pokemon) // nil 
}

字符串和 Foundation

  • String实例和NSString实例可以通过as进行转化。

  • Swift 5.0 中,String 依然缺少很多NSString 中所拥有的功能。String 受到了编译器的特殊对待,引入 Foundation 后,NSString 的成员就都可以在 String 实例上进行访问了。

  • 两个库有一些重叠的特性,有时候会有两个名字完全不同的 API,但是它们做的事情却几乎一样。

    • 标准库中的 split 方法和 Foundation 里的components(separatedBy:)

    • 标准库是围绕布尔值来设计断言的,Foundation 使用ComparisonResult 来表示比较断言的结果。

      assert

      let valueId = "666"
      assert(valueId.isEmpty == true) // crash
      

      ComparisonResult

      let result = valueId.compare("777")
      print(result.rawValue) // -1
      
    • enumerateSubstrings(in:options:_:) 这个使用字符串和范围来对输入字符串按照字位簇、单词、句子或者段落进行迭代的超级强力的方法,在 Swift 中对应的 API 使用的是子字符串

      let sentence = """
                     The quick brown fox jumped
                     over the lazy dog.
                     """
      var words: [String] = []
      sentence.enumerateSubstrings(in: sentence.startIndex..., options: .byLines) { (word, range, _, _) in
          guard let word = word else { return }
          words.append(word)
      }
      print(words)//["The quick brown fox jumped", "over the lazy dog."]
      
  • Swift 字符串在内存中的原生编码是 UTF-8,NSString 是 UTF-16,会导致Swift 字符串桥接到 NSString 时会有一些额外的性能开销。比如enumerateSubstrings(in:options:using:)中传递NSString会比传递String快。因为NSString在以 UTF-16 计算的偏移上移动位置消耗的是常量时间,而在String上是一个花费线性时间的操作。

其他基于字符串的 Foundation API

  • 原生的 NSString API 对于 Swift 字符串来说,是使用起来最方便的 API。因为编译器为你完成了大部分的桥接工作。

  • 其他很多Foundation中处理字符串的API,因为 Apple还没有为它们创造特殊的 Swift 封装层,使用起来就有点不友好了。比如NSAttributedString

    • NSAttributedString(不可变字符串),NSMutableAttributedString(可变字符串),都遵守引用语义。
    • NSAttributedString 的 API 原来接受的是 NSString,但是它现在接受一个Swift.String。不过整个 API 的基础还是 NSString 的 UTF-16 编码单元集合的概念。频繁地在 String 和 NSString 之间发生桥接可能会带来意外的性能开销。
    //为字符串中"Click here"添加了一个链接
    
    let text = "👉 Click here for more info."
    let linkTarget = URL(string: "https://www.youtube.com/watch?v=DLzxrzFCyOs")!
    // 尽管使用了 `let`,对象依然是可变的 (引用语义)
    let formatted = NSMutableAttributedString(string: text)
    // 修改文本的部分属性
    if let linkRange = formatted.string.range(of: "Click here") {
        // 将 Swift 范围转换为 NSRange
        // 注意范围的起始值为 3,因为文本前面的颜文字无法在单个 UTF-16 编码单元中被表示
        let nsRange = NSRange(linkRange, in: formatted.string) // {3, 10}
        // 添加属性
        formatted.addAttribute(.link, value: linkTarget, range: nsRange)
    }
    
    //通过特定的字符位置,来查询属性字符串中的格式属性
    
    // 查询单词 "here" 开始的属性
    if let queryRange = formatted.string.range(of: "here") {
        // 把 Swift range 转换成 NSRange
        let nsRange = NSRange(queryRange, in: formatted.string)
        // 准备用来接收属性影响范围的 NSRange 变量
        var attributesRange = NSRange()
        // 执行查询
        let attributes = formatted.attributes(at: nsRange.location, effectiveRange: &attributesRange)
        attributesRange // {3, 10}
        // 把 NSRange 再变回 Range<String.Index>
        if let effectiveRange = Range(attributesRange, in: formatted.string) {
            // 被查询到的属性包围的子字符串
            formatted.string[effectiveRange] // Click here
        }
    }
    

    这样的代码距离真正的 Swift 惯用写法,还相去甚远。

字符范围

  • 无法遍历字符范围

    let lowercaseLetters = ("a" as Character)..."z"
    //ClosedRange<Character>
    
    for c in lowercaseLetters { // 错误
        ...
    }
    
    //这里将 “a” 转换为 Character 是必要的,否则字符串字面量的默认类型将是 String
    //Character并没有实现Strideable协议,而只有实现了这个协议的范围才是可数的集合
    

    image.png

  • 对于一个字符范围,唯一能够进行的操作是将它和其他字符进行比较。

    let lowercaseLetters = ("a" as Character)..."z"
    lowercaseLetters.contains("A") // false
    lowercaseLetters.contains("é") // false
    
  • 对 Unicode.Scalar 类型来说,当你保持在 ASCII 或者其他一些有很好排序的 Unicode类别的子集时,可数范围的概念就有意义了。Unicode 标量的顺序是通过它们的代码点的值进行定义的,所以在两个边界之间,一定存在的有限个数的标量。

    extension Unicode.Scalar: Strideable {
        public typealias Stride = Int
        public func distance(to other: Unicode.Scalar) -> Int {
            return Int(other.value) - Int(self.value)
        }
        public func advanced(by n: Int) -> Unicode.Scalar {
            return Unicode.Scalar(UInt32(Int(value) + n))!
        }
    }
    
    //通过它创建一个可数的 Unicode 标量范围,生成一个字符数组
    let lowercase = ("a" as Unicode.Scalar)..."z"
    for c in lowercase {} //不报错了
    print(Array(lowercase.map(Character.init)))
    
    /*
    ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n",
    "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
    */
    

CharacterSet

是个Foundation 类型,这个结构体实际上应该被叫做 UnicodeScalarSet,因为它确实就是一个表示一系列 Unicode 标量的数据结构体,完全和 Character 类型不兼容。

image.png

let favoriteEmoji = CharacterSet("👩‍🚒👨‍🎤".unicodeScalars)
favoriteEmoji.contains("🚒") // true

//因为女消防员的颜文字实际上是女人 + ZWJ + 消防车的组合

Unicode 属性

在 Swift 5 里,不再需要Foundation 中的类型来测试一个标量是否属于某个官方的Unicode分类了,现在只要直接访问 Unicode.Scalar 中的某个属性就好了,例如:isEmoji 或者 isWhiteSpace。为了避免在Unicode.Scalar 中塞入过多的成员,所有 Unicode 属性都放在了 properties 这个名字空间里。

("😀" as Unicode.Scalar).properties.isEmoji // true
("∬" as Unicode.Scalar).properties.isMath // true

现在列出字符串中每一个标量的编码点、名称和一般分类只需要对字符串做一点格式化就行了

"I’m a 👩🏽‍🚒.".unicodeScalars.map { scalar -> String in
    let codePoint = "U+\(String(scalar.value, radix: 16, uppercase: true))"
    let name = scalar.properties.name ?? "(no name)"
    return "\(codePoint): \(name) – \(scalar.properties.generalCategory)"
}.joined(separator: "\n")
/*
U+49: LATIN CAPITAL LETTER I – uppercaseLetter
U+2019: RIGHT SINGLE QUOTATION MARK – finalPunctuation
U+6D: LATIN SMALL LETTER M – lowercaseLetter
U+20: SPACE – spaceSeparator
U+61: LATIN SMALL LETTER A – lowercaseLetter
U+20: SPACE – spaceSeparator
U+1F469: WOMAN – otherSymbol
U+1F3FD: EMOJI MODIFIER FITZPATRICK TYPE-4 – modifierSymbol
U+200D: ZERO WIDTH JOINER – format
U+1F692: FIRE ENGINE – otherSymbol
U+2E: FULL STOP – otherPunctuation
*/

Unicode标量的这些属性非常底层,它们主要是为表达Unicode中那些不为人熟知的术语而定义的。如果在更为常用的Character这个层面也提供一些类似的分类。

Character("4").isNumber // true
Character("$").isCurrencySymbol // true
Character("\n").isNewline // true

String 和 Character 的内部结构

  • 字符串是写时复制的。(创建一个字符串的复制,或者创建一个子字符串时,所有这些实例都共享同样的缓冲区。字符数据只有当与另外一个或多个实例共享缓冲区,且某个实例被改变时,才会被复制)

  • Swift 5 里,Swift 原生字符串 在内存中是用 UTF-8 格式表示的,通过它可以获取理论上字符串处理的最佳性能,因为遍历 UTF-8 视图要比遍历 UTF-16 或 Unicode 标量视图更快。

  • 从 Objective-C 接收到的字符串则是通过一个 NSString 表示的,在这种时候,为了让桥接尽可能高效,一个基于 NSString 的 String 在被改变时,将会被转换为原生的 Swift 字符串。

  • 对于那些小于16个 UTF-8 编码单元的小型字符串,作为特别优化,Swift 并不会为其创建专门的存储缓冲区。由于字符串最多只有 16 字节,这些编码单元可以用内连的方式存储。

字符串字面量

可以通过实现 ExpressibleByStringLiteral 协议让你自己的类型支持通过字符串字面量进行初始化。

当使用 SafeHTML 值的时候,我们可以确保它表示的字符串中,所有有潜在风险的 HTML 标签都已经被转义了,优点:可以避免招致一些安全问题。缺点:要在调用这些 API 之前写很多包装字符串的代码。

extension String {
    var htmlEscaped: String {
        return replacingOccurrences(of: "<", with: "&lt;")
            .replacingOccurrences(of: ">", with: "&gt;")
    }
}

struct SafeHTML {
    private(set) var value: String
    init(unsafe html: String) {
        self.value = html.htmlEscaped
    }
}

let safe: SafeHTML = SafeHTML(unsafe: "<p>Angle brackets in literals are not escaped</p>")
print(safe)//SafeHTML(value: "&lt;p&gt;Angle brackets in literals are not escaped&lt;/p&gt;")

SafeHTML 实现 ExpressibleByStringLiteral,保证安全的同时,免去复杂的代码处理。

extension SafeHTML: ExpressibleByStringLiteral {
    public init(stringLiteral value: StringLiteralType) {
        self.value = value
    }
}

let safe: SafeHTML = "<p>Angle brackets in literals are not escaped</p>"
print(safe)//SafeHTML(value: "<p>Angle brackets in literals are not escaped</p>")

字符串插值

可以让我们在字符串字面量中插入表达式 例如:"a * b = \(a * b)"

Swift 5 则进一步开放了公共 API,可以支持在构建自定义类型时使用字符串插值。

let input = ... // 这部分由⽤户输⼊,不安全!
let html = "<li>Username: \(input)</li>"

上述代码,input 中的内容必须被转义后使用,因为它的来源并不安全。但 html 变量中字面量的分段不应发生变化,因为我们在这里就是要写入带有 HTML 标签的值。为了实现这个逻辑,我们可以给SafeHTML 创建一个自定义的字符串插值规则。

Swift 的字符串插值 API 由两个协议组成:ExpressibleByStringInterpolationStringInterpolationProtocol

demo:

final class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let unsafeInput = "<script>alert('Oops!')</script>"
        let safe2: SafeHTML = "<li>Username: \(unsafeInput)</li>"
        print(safe2)//SafeHTML(value: "<li>Username: &lt;script&gt;alert(\'Oops!\')&lt;/script&gt;</li>")

        let star = "<sup>*</sup>"
        let safe3: SafeHTML = "<li>Username\(raw: star): \(unsafeInput)</li>"
        print(safe3)
    }
}

//MARK: - 字符串插值

extension SafeHTML: ExpressibleByStringInterpolation {
    //StringInterpolationProtocol的方法执行完,才会调用此init方法创建一个对象
    public init(stringInterpolation: SafeHTML) {
        value = stringInterpolation.value
    }
}

extension SafeHTML: StringInterpolationProtocol {
    /*插值类型大约需要多少空间存储所有要合并的字面量,以及期望的插值数量。
    如果我们关注插值操作的性能,还是要通过这两个参数告知编译器关于预留空间的信息*/
    init(literalCapacity: Int, interpolationCount: Int) {
        value = ""
    }

    //非插值部分
    mutating func appendLiteral(_ literal: String) {
        value += (literal)
    }

    //插值部分
    mutating func appendInterpolation<T>(_ x: T) {
        value += (String(describing: x).htmlEscaped)
    }
}

extension SafeHTML {
    //仅仅是扩展appendInterpolation方法
    mutating func appendInterpolation<T>(raw x: T) {
        self.value += String(describing: x)
    }
}

定制字符串描述

  • 自定义SafeHTML类型,用print打印:

    struct SafeHTML {
        private(set) var value: String
        init(unsafe html: String) {
            value = html
        }
    }
    
    let safe: SafeHTML = SafeHTML(unsafe: "<p>Hello, World!</p>")
    
    print(safe) // SafeHTML(value: "<p>Hello, World!</p>")
    print(String(describing: safe)) // SafeHTML(value: "<p>Hello, World!</p>")
    print(String(reflecting: safe)) // SafeHTML(value: "<p>Hello, World!</p>")
    
  • SafeHTML遵循CustomStringConvertible协议:

    extension SafeHTML: CustomStringConvertible {
        var description: String {
            return value
        }
    }
    
    let safe: SafeHTML = SafeHTML(unsafe: "<p>Hello, World!</p>")
    
    print(safe) // <p>Hello, World!</p>
    print(String(describing: safe)) // <p>Hello, World!</p>
    print(String(reflecting: safe)) // <p>Hello, World!</p>
    
  • SafeHTML遵循CustomDebugStringConvertible协议:

    extension SafeHTML: CustomDebugStringConvertible {
        var debugDescription: String {
            return "Debug: \(value)"
        }
    }
    
    let safe: SafeHTML = SafeHTML(unsafe: "<p>Hello, World!</p>")
    
    print(safe) // Debug: <p>Hello, World!</p>
    print(String(describing: safe)) // Debug: <p>Hello, World!</p>
    print(String(reflecting: safe)) // Debug: <p>Hello, World!</p>
    
  • 如果让SafeHTML同时遵循上述CustomStringConvertibleCustomDebugStringConvertible协议:

    let safe: SafeHTML = SafeHTML(unsafe: "<p>Hello, World!</p>")
    
    print(safe) // <p>Hello, World!</p>
    print(String(describing: safe)) // <p>Hello, World!</p>
    print(String(reflecting: safe)) // Debug: <p>Hello, World!</p>
    

    结论: 但如果你没有实现 CustomDebugStringConvertible,String(reflecting:) 就会选择使用CustomStringConvertible 提供的结果,如果你的类型没有实现CustomStringConvertible,String(describing:) 会选择使用CustomDebugStringConvertible 提供的结果。

    作者的建议:

    • 如果你的自定义类型比简单,就没必要实现 CustomDebugStringConvertible。
    • 如果你的定义类型是个容器,让它实现CustomDebugStringConvertible 则是一种更为友好的行为,通过它,可以打印容器中每个元素在调试模式的信息。
    • 当你为了调试打印结果之后还要做一些特别的处理,也应该通过实现 CustomDebugStringConvertible 完成。
    • 如果你为description 和debugDescription 提供的结果相同,那么二者选其一去实现就好了。
  • Array 总是会打印它包含的元素的调试版信息,即使你把它传递给String(describing:)。这是因为数组的普通字符串描述永远都不应该对用户呈现。(因为比如空字符串 "",String.description 会忽略包围字符串的引号)

    let str = ""
    print(str) // (啥都没有)
    print(String(describing: str)) // (啥都没有)
    print(String(reflecting: str)) // ""
    
    let array: [String] = ["", "", ""]
    print(array) // ["", "", ""]
    print(String(describing: array)) // ["", "", ""]
    print(String(reflecting: array)) // ["", "", ""]
    

文本输出流

遵循TextOutputStream协议的,可作为输出目标

标准库中的 printdump 函数会把文本记录到标准输出中。两个函数的默认实现调用了print(_:to:)dump(_:to:)to 参数就是输出的目标,它可以是任何实现了 TextOutputStream 协议的类型。

  • String 是标准库中唯一的输出流类型

    var s = "" 
    let numbers = [1, 2, 3, 4]
    print(numbers, to: &s) 
    print(s) // [1, 2, 3, 4]
    
  • 创建自己的输出流:遵循 TextOutputStream 协议,创建一个接受字符串的变量,并将它写到流中的 write 方法:

    struct ArrayStream: TextOutputStream {
        var buffer: [String] = []
        mutating func write(_ string: String) {
            buffer.append(string)
        }
    }
    
    var stream = ArrayStream()
    print("Hello", to: &stream)
    print("World", to: &stream)
    print(stream.buffer) // ["", "Hello", "\n", "", "World", "\n"]
    //文档明确允许那些将输出写到输出流的函数在每次写操作时可以多次调用 write(_:),所以出现了"""\n"等。
    
  • 扩展 Data 类型,让它接受流输入,并输出 UTF-8 编码的结果。

    extension Data: TextOutputStream {
        mutating public func write(_ string: String) {
            self.append(contentsOf: string.utf8)
        }
    }
    
    var utf8Data = Data()
    utf8Data.write("café")
    print(Array(utf8Data)) // [99, 97, 102, 195, 169]
    

    使用print,将和上述得到结果一样:

    var utf8Data = Data()
    print("café", to: &utf8Data)
    print(Array(utf8Data)) // [99, 97, 102, 195, 169]
    

遵循TextOutputStreamable协议的,可作为输出源

Demo:

struct ReplacingStream: TextOutputStream, TextOutputStreamable {
    let toReplace: KeyValuePairs<String, String> //使用KeyValuePairs的目的:不会去掉重复的键、不会将所有键重新排序。

    private var output = ""

    init(replacing toReplace: KeyValuePairs<String, String>) {
        self.toReplace = toReplace
    }

    mutating func write(_ string: String) {
        let toWrite = toReplace.reduce(string) { partialResult, pair in
            partialResult.replacingOccurrences(of: pair.key, with: pair.value)
        }
        print(toWrite, terminator: "", to: &output)
    }

    func write<Target>(to target: inout Target) where Target : TextOutputStream {
        output.write(to: &target)
    }
}

var replacer = ReplacingStream(replacing: ["in the cloud": "on someone else's computer"])
let source = "People find it convenient to store their data in the cloud."
print(source, terminator: "", to: &replacer)
var finalSource = ""
print(replacer, terminator: "", to: &finalSource)
print(finalSource) // People find it convenient to store their data on someone else's computer.”

执行过程:

  • ReplacingStream遵循2个协议,所以它可作为输出源输出目标
  • print(source, terminator: "", to: &replacer): 将source经过处理后,传输给ReplacingStream里面的output。(ReplacingStream做输出目标)
  • print(replacer, terminator: "", to: &finalSource):将ReplacingStream中的output传输给外部的finalSource。(ReplacingStream做输出源)