木又的《Swift进阶》读书笔记——字符串

499 阅读8分钟

字符串

  • Swift 字符串不支持随机访问

Unicode,而非固定宽度

今天的 Unicode 是一个可变长格式。它的可变长特性有两种不同的意义:

  • 一个 Unicode 字符,也叫做扩展字位簇 (extended grapheme cluster),由一个或多个 Unicode 标量 (Unicode scalar) 组成。
  • 一个 Unicode 标量可以被编码成一个或多个编码单元 (code units)

Unicode 中组基础的原件叫做编码点 (code point):它是一个位于 Unicode 编码空间 (从 0 到 0x10FFFF,也就是十进制的 1,114,111) 中的整数。

Unicode 标量 和当才提到的编码点,在绝大多数情况下,是同一个东西。或者说,除了编码点中 0xB800 - 0xDFFF 之外的值,都可以叫做 Unicode 标量。而 0xD800 - 0xDFFF 这2048个值则叫做 代理编码点(surrogate code points),它们在 UTF-16 编码中用于表示那些值大于 65525 的字符。

同样的 Unicode 数据可以用多种不同的编码方式进行编码,其中最普遍使用的是 8 比特 (UTF-8) 和 16 比特 (UTF-16)。编码方式中使用的最小实体叫做编码单元,也就是说UTF-8编码单元的宽度是8比特,而UTF-16编码单元的宽度是16比特。

用户在屏幕上看到的“单个字符”,可能是由多个Unicode标量组合起来的。对于这种用户感知到的“单个字符”,Unicode 中有一个术语,叫做 (扩展)字位簇,对应的英文叫做 (extended) grapheme cluster

字位簇和标准等价

合并标记

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

single.unicodeScalars.count // 7
double.unicodeScalars.count // 8

颜文字

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

字符串和集合

双向索引,而非随机访问

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

范围可替换,而非可变

  • String 还满足 RangeReplaceableCollection 协议。

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

字符串索引

  • Swift 不允许使用整数值对字符串进行下标操作。因为整数的下标访问无法在常数时间内完成 (对于 Collection 协议来说这也是个直观要求),而且查找第 n 个 Character 的操作也必须要对她之前的所有字节进行检查。

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

    let s = "abcdef"
    let second = s.index(after: s.startIndex)
    s[second] // b
    
  • 扩展分隔符语法(extended delimiters syntax),就是把字符串用 # 包围起来。这样就可以在字符串直接使用引用而不用转义了。

    let scv = #"""
    	"Value in quotes","can contain, characters"
    	"Values without quotes work as well:", 42
    	"""#
    

子字符串

  • 和所有集合类型一样,String 有一个特定的 SubSequence 类型,叫做 Substring。Substring 和 ArraySlice 很相似:它是一个以原始字符串内容为基础,用不同起始和结束位置标记的视图。

StringProtocol

  • Substring 和 String 的接口几乎完全一样。这是通过一个叫做 StringProtocol 的通用协议来达到的,String 和 Substring 都遵守这个协议。

    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")
    
  • 不鼓励长期存储字符串的根本原因在于,子字符串会一直持有整个原始字符串。

  • 通过在一个操作内部使用子字符串,而只在结束时创建新字符串,我们将复制操作推迟到最后一刻,这可以保证由这些复制操作所带来的的开销是实际需要的。

  • 在大部分 API 中只使用 String, 而不是将它换为泛型 (其实泛型本身也会带来开销),会更加简单和清晰。但那些极有可能处理子字符串,同时又无法进一步泛型化成 Sequence 或 Collection 一般操作的API,则不适用上述规则。

    extension Sequence where Element: StringProtocol {
      /// 将一个序列中的元素使用给定的分隔符拼接起来为新的字符串,并返回
      public func joined(separator: String = "") -> String
    }
    
  • 如果你想要扩展 String 为其添加新的功能,将这个扩展放在 StringProtocol 会是一个好主意,这可以保持 String 和 Substring API 的统一性。StringProtocol 设计之初就是为了在你想要对 String 扩展时来使用的。

  • StringProtocol 并不是一个你想要构建自己的字符串类型时所应该实现的目标协议。

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

编码单元视图

  • 有时候字位簇无法满足需要时,我们还可以向下到比如 Unicode 标量或者编码单元这样更低的层次中进行查看和操作。String 为此提供了三种视图: unicodeScalars, utf16 和 utf8。
  • UTF-8 是用来存储或者在网络上发送文本的事实标准。UTF-8 视图是 String 所有编码单元视图中,系统开销最低的。因为它是 Swift 字符串在内存中的原生存储格式。
  • 要注意的是,utf8 集合不包含字符串尾部的 null 字节。如果你需要用 null 表示结尾的话,可以使用 String 的 withCString 方法或者 utf8CString 属性。

共享索引

  • 字符串和它们的视图共享同样的索引类型,String.Index。

字符串和 Foundation

  • Swift 的 String 类型和 Foundation 的 NSString 有着非常密切的关系。任意的 String 实例都可以通过 as 操作桥接转换为 NSString,而且那些接受或者返回 NSString 的 Objective-C API 也会把类型自动转换为 String。
  • Swift 字符串在内存中的原生编码是 UTF-8,而 NSString 是UTF-16,这种差异会导致 Swift 字符串桥接到 NSString 时会有一些额外的性能开销。对于 NSString,在以 UTF-16 计算的偏移上移动位置消耗的是常量时间,而这对于 Swift 字符串来说,是一个花费线性时间的操作。为了减少这个性能上的差异,Swift 实现了一种非常复杂却高效的索引缓存方法,使得这些原本线性时间的操作可以达到 均摊常量时间 (amortized constant time) 的性能。

其他基于字符串的 Foundation API

  • NSAttributedString 对应不可变字符串,NSMutableAttributedString 对应可变字符串。和 Swift 标准库中遵守值语义的集合不同,它们都遵守引用语义。

  • NSRange 是一个包含两个整数字段 location 和 length 的结构体:

    public struct NSRange {
      public var location: Int
      public var length: Int
    }
    

字符范围

  • Character 并没有实现 Strideable 协议,而只有实现了这个协议的范围才是 可数 的集合。

CharacterSet

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

Unicode 属性

  • 在 Swift5 里,CharacterSet 的部分功能被移植到了 Unicode.Scalar。

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

    你可以在 Unicode.Scalar.Porperties 找到完整的属性列表。

String 和 Character 的内部结构

  • 和标准库中的其他集合类型一样,字符串也是一个实现了写时复制的值语义类型。
  • 在 Swift 5 里,Swift 原生字符串 (相对于从 Objective-C 接收的字符串) 在内存中是用 UTF-8 格式表示的。
  • 对于那些小于16个 (在32位平台上是小于11个) UTF-8 编码单元的小型字符串,作为特别优化,Swift 并不会为其创建专门的存储缓冲区。由于字符串最多只有16字节,这些编码单元可以用内连的方式存储。
  • 一个字符现在在内部被表示成长度为 1 的字符串。

字符串字面量

  • "" 是字符串字面量。我们可以通过实现 ExpressibleByStringLiteral 协议让你自己的类型支持通过字符串字面量初始化。

字符串插值

字符串插值(String interpolation) 是从 Swift 发布之初就存在的语法特性。他可以让我们在字符串字面量中插入表达式 (例如: "a * b = \(a * b)")。

定制字符串描述

  • 通过实现 CustomStringConvertible 自定义 printString(describing) 输出。通过实现 CustomDebugStringConvertible 自定义 String(reflecting:) 的结果。

    extension SafeHTML: CustomStringConvertible {
      var description: String {
        return value
      }
    }
    
    extension SafeHTML: CustomDebugStringConvertible {
      var debugDescription: String {
        return "SafeHTML: \(value)"
      }
    }
    

文本输出流

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

    public func print<Target: TextOutputStream>
    	(_ items: Any..., separator: String = " ",
      terminator: String = "\n", to output: inout Target)
    
  • 我们还可以创建自己的输出流。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)
    stream.buffer // ["","Hello","\n","","World","\n"]
    
  • 输出流的源可以是实现了 TextOutputStreamable 协议的任意类型。这个协议需要 write(to:) 这个泛型方法,它可以接受满足 TextOutputStream 的任意类型作为输入,并将 self 写到这个输出流中。

    struct StdErr: TextOutputStream {
      mutating func write(_ string: String) {
        guard !string.isEmpty else { return }
        
        // Swift 字符串可以直接传递给那些接受 const char *
        fputs(string, stderr)
      }
    }
    
    var standarderror = StdErr()
    print("oops!", to: &standarderror)
    
  • 流还能够持有状态,或者对输出进行变形。除此之外,你也能够将多个流链接起来。

    struct ReplacingStream: TextOutputStream, TextOutputStreamable {
      let toReplace: DictionaryLiteral<String, String>
      private var output = ""
      
      init(replacing toReplace: DictionaryLiteral<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: TextOutputStream>(to target: inout Target) {
        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 output = ""
    print(replacer, terminator: "", to: &output)
    output
    // People find it convenient to store their data on someone else's computer.