Swift的String、Array分析

258 阅读13分钟

1、String

1.1、String源码分析

  • 首先我们来看一下空字符串的
    var empty = "" 
    print(empty)
    
  1. 查找源码我们需要从初始化入手,其中的 init 方法调用了内部的init 方法,该方法接收一个 _StringGuts 的对象作为参数
      /// Creates an empty string.
      ///
      /// Using this initializer is equivalent to initializing a string with an
      /// empty string literal.
      ///
      ///     let empty = ""
      ///     let alsoEmpty = String()
      @inlinable @inline(__always)
      @_semantics("string.init_empty")
      public init() { self.init(_StringGuts()) }
    
  2. String结构体结构 持有 _StringGuts 作为成员变量,所以我们接下来关注的重点就是 _StringGuts 这个属性,我们直接来到 StringGuts.swift 这个文件来找到它的定义:
    // StringGuts is a parameterization over String's representations. It provides
    // functionality and guidance for efficiently working with Strings.
    //
    @frozen
    public // SPI(corelibs-foundation)
        struct _StringGuts: UnsafeSendable {
        @usableFromInline
        internal var _object: _StringObject
    
        @inlinable @inline(__always)
        internal init(_ object: _StringObject) {
            self._object = object
            _invariantCheck()
        }
    
        // Empty string
        @inlinable @inline(__always)
        init() {
            self.init(_StringObject(empty: ()))
        }
    }
    
    • 初始化方法中需要一个StringObject作为成员变量
      internal var _object: _StringObject
      
    • _StringObject 也是一个结构体,我们找到 _StringObject(empty: ()) 这个方法:
      @inlinable @inline(__always)
        internal init(empty:()) {
          // Canonical empty pattern: small zero-length string
      #if arch(i386) || arch(arm) || arch(arm64_32) || arch(wasm32)
          self.init(
            count: 0,
            variant: .immortal(0),
            discriminator: Nibbles.emptyString,
            flags: 0)
      #else
          self._countAndFlagsBits = 0
          self._object = Builtin.valueToBridgeObject(Nibbles.emptyString._value)
      #endif
          _internalInvariant(self.smallCount == 0)
          _invariantCheck()
        }
      
    • 可以看到在判断条件的分支中,调用了 init(count: variant: discirminator: flags:) 这个方法,同样的这几个都是结构体 StringObject 的成员变量
      @inlinable @inline(__always)
      init(count: Int, variant: Variant, discriminator: UInt64, flags: UInt16) {
          _internalInvariant(discriminator & 0xFF00_0000_0000_0000 == discriminator,
          "only the top byte can carry the discriminator and small count")
      
          self._count = count
          self._variant = variant
          self._discriminator = UInt8(truncatingIfNeeded: discriminator &>> 56)
          self._flags = flags
          self._invariantCheck()
      }
      
      • _count 应该是代表当前字符串的大小
      • _variant的类型是 Variant,它是一个枚举,这个枚举代表字符串的三种情况,分别为 immortalnative 以及 bridged;通过刚才初始化方法的传值,此时的 _variant 类型是 Variant.immortal(0) 类型的,这个代表 Swift 原生的字符串类型;其结构如下:
        internal enum Variant {
            case immortal(UInt)
            case native(AnyObject)
            case bridged(_CocoaString)
            // function
            ......
        }
        
      • _discriminator 外部传进来的是一个 Nibbles.emptyString 下面来看一下 Nibbles 是什么
          // Namespace to hold magic numbers
          @usableFromInline @frozen
          enum Nibbles {}
        
        它是一个什么都没有的枚举,这里只进行了定义,那它可能在其他位置进行了实现,最后找到了它的 extension
        extension _StringObject.Nibbles {
          // The canonical empty string is an empty small string
          @inlinable @inline(__always)
          internal static var emptyString: UInt64 {
            return _StringObject.Nibbles.small(isASCII: true)
          }
        }
        
        extension _StringObject.Nibbles {
          // Discriminator for small strings
          @inlinable @inline(__always)
          internal static func small(isASCII: Bool) -> UInt64 {
            return isASCII ? 0xE000_0000_0000_0000 : 0xA000_0000_0000_0000
          }
        
        emptyString 返回的是一个 Nibbles 的 samll(isASCII:)(判断是否为小字符串) 方法,true 代表是 ASCII,此时返回是 0xE000_0000_0000_0000,false 代表不是 ASCII,此时返回 0xA000_0000_0000_0000

1.2、小字符串

  • 一个空的字符串,打印输出的结果如下: image.png

  • 对于一个英文字符串"abc",打印如下: image.png

  • 对于一个包含中文的字符串打印输入结果如下:(中文单字符占据位数要比英文多) image.png

  • 看到这里我们已经明白了,0xa 、0xe 这里是用来标识当前小字符串是否是ASCII码(大字符串用0x8表示)

    • 中文字符不属于ASCII
    • 其中后面的数字是用来标志当前的的 字符串的大小
    • 前面的 636261 是 abc 的编码,也就是 636261 代表的就是 abc
  • 我们都知道一个字符串的大小为 16 个字节,那这些字符是否都是存储在这 16 个字节中呢?我们查看"abcdefghijklmno"这15个字母长的字符串怎么存储;选15个是因为上边说了,有1位用来存储了字符串大小,所以选了这个临界值长度 image.png

    • 原来前边存满了后,接着字符串大小标志接着存到满
    • 那么问题又来了,如果再比这个字符串长呢?这里就不能使用 小字符串 了,而是变成了 大字符串

1.3、大字符串

  • 我们来看一下"abcdefghijklmnoabc"的内存打印结果 image.png

  • 此时这 16 个字节存储的内容就变了,变成什么了呢?在源码中有这么一段注释: 0ffc5dbae9794cf7ab8a039ccfe1c88e~tplv-k3u1fbpfcp-watermark.image.png

    • 当字符串的大小小于 15 的时候属于小字符串,当字符串的大小大于 15 的时候属于大字符串;。通过官方的注释得知 大字符串可以是原生的、共享的或者是外来的(这里主要探究原生的字符串)。这个原生的字符串具有尾部分配的存储空间,它从存储对象地址的 nativeBias 偏移量开始
    • 也就是意味着当字符串的大小大于 15 的时候,会分配额外的存储空间,用这个额外的存储空间存储字符串的值 fad81d02e6e94258ac331d199e5fdb94~tplv-k3u1fbpfcp-watermark.image.png
      • 首先 nativeBias 的值为 32。接下来在 64 位中, discriminator(鉴别器) 和 objectAddr,根据官方给的注释,这个 discriminator 占据的位置是高 63 位到高 60 位,objectAddr 占据的位置是高 60 位到低 0 位,存储的就是这个额外的存储空间的内存地址

        • 其中 _discriminator 占据 4 位,每一位的标识如下:
            ┌─────────────────────╥─────┬─────┬─────┬─────┐
             Form                 b63  b62  b61  b60 
            ╞═════════════════════╬═════╪═════╪═════╪═════╡
             Immortal, Small       1  ASCII  1    0  
            ├─────────────────────╫─────┼─────┼─────┼─────┤
             Immortal, Large       1    0    0    0  
            ╞═════════════════════╬═════╪═════╪═════╪═════╡
             Native                0    0    0    0  
            ├─────────────────────╫─────┼─────┼─────┼─────┤
             Shared                x    0    0    0  
            ├─────────────────────╫─────┼─────┼─────┼─────┤
             Shared, Bridged       0    1    0    0  
            ╞═════════════════════╬═════╪═════╪═════╪═════╡
             Foreign               x    0    0    1  
            ├─────────────────────╫─────┼─────┼─────┼─────┤
             Foreign, Bridged      0    1    0    1  
            └─────────────────────╨─────┴─────┴─────┴─────┘
          
      • 这个 objectAddr 存储的是这个额外的存储空间的 相对内存地址,因为它需要加上 nativeBias,得到的才是这个额外的存储空间的地址值

  • 此时回到 abcdefghijklmnoabc 的内存布局,前面的 8 个字节(0xd000000000000012)我们先不管,看后面的 8 个字节(0x8000000100003f60)。0x8 就是这个鉴别器 discriminator 的值,后面的 100003f60 就是额外存储空间 objectAddr 的相对地址。所以我们来试着加一下 nativeBias 的到的值后是否就是字符串的存储空间

    100003f60 + 32 = 100003f60 + 0x2 = 100003f80
    
    • 我们格式化输出一下 0x100003f80: 12b0ab9746814bf1a9f622e721a9d3fb~tplv-k3u1fbpfcp-watermark.image.png 果然,0x100003f80 就是字符串的内存地址,那我怎么知道这个 0x8 就是鉴别器的值呢?因为在源码中直接规定了,代码如下:
      // Discriminator for large, immortal, swift-native strings
      @inlinable @inline(__always)
      internal static func largeImmortal() -> UInt64 {
          return 0x8000_0000_0000_0000
      }
      
    • 对于原生的 Swift 字符串来说,采取的是 tail-allocated 存储,也就是在当前实例分配有超出其最后存储属性的额外空间,额外的空间可用于直接在实例中存储任意数据
  • 现在我们再来看看刚才搁置的8个字节,官方有对于大字符串标志位的描述: 19f4535421ee4725a6018b519f52c93e~tplv-k3u1fbpfcp-watermark.image.png

    • isASCII:用来判断当前字符串是否是 ASCII,在高 63 位。
    • isNFC:这个默认为 1,在高 62 位。
    • isNativelyStored:是否是原生存储,在 高 61 位。
    • isTailAllocated:是否是尾部分配的,在 高 60 位。
    • TBD:留作将来使用,在高 59 位到高 48 位。
    • count:当前字符串的大小,在高 47位到低 0 位。 image.png 0x12 的十进制是 18,所以当前字符串的大小为 18。所以当字符串为大字符串的时候,前 8 个字节存储的就是 countsAndFlags

总结

  • 一个 String 变量/常量的大小为 16 个字节

  • 当字符串的大小 不超过 15 的时候为小字符串,超过 15 的时候为大字符串

  • 当为小字符串的时候,在 16 字节的大小中,前 15 个字节用来存储字符串的值最后一个字节用来记录当前字符串是否是 ASCII 和字符串的大小

  • 当为大字符串的时候,在 16 字节的大小中,前 8 个字节用来记录字符串的大小和其它的一些信息,比如是否是 ASCII。剩余的 8 个字节中,高 63 位到高 60 位存储的是鉴别器的值,60 位到 0 位 存储字符串的存储空间的地址值

2、Swift Index

  • 在Swift中,获取字符串中某一个字符的值我们可以通过 Index 去获取

    • index(i: offsetBy: ):获取字符串偏移多少位字符的String.index?

    1. 我们首先准备一段字符串"Hello World"
      let str = "Hello World"
      
    2. 我们取出这段字符串中的第7个字符
      let index = str.index(str.startIndex, offsetBy: 6 , limitedBy:str.endIndex)
      print(str[index!])   
      
      //打印结果
      W
      
  • 我们想要获取字符串中的第 n 位字符,Swift通过把 n 转化成一个 String.index,再通过其定位到该字符,但是为什么不能通过角标直接获取呢?

  • Swift String 代表的是什么? 一系列的 characters (字符),字符的表示方式有很多种,比如我们最熟悉的 ASCII码,ASCII 码一共规定了128个字符的编码,对于英文字符来说 128 个字符已经够用了,但是相对于其他语言来说,这是远远不够用的。这也就意味着不同国家不同语言都需要有自己的编码格式,这个时候同一个二进制文件就有可能被翻译成不同的字符,有没有一种编码能够把所有的符号都纳入其中---这就是我们熟悉的Unicode ,但是 Unicode 只是规定了符号对应的二进制代码,并没有详细明确这个二进制代码应该如何存储。

    • 什么意思呢,假设这里有一个字符串:"我是Coder",那这个字符串对应的 Unicode 如下:

      我:6211
      是:662f
      C:0043
      o:006f
      d:0064
      e:0065
      r:0072
      

      可以看到,上述的文字每一个对应一个十六进制的数,对于计算机来说能够识别的是二进制,那它们的二进制表示如下:

      我:0110 0010 0001 0001
      是:0110 0110 0010 1111
      C:0000 0000 0100 0011
      o:0000 0000 0110 1111
      d:0000 0000 0110 0100
      e:0000 0000 0110 0101
      r:0000 0000 0111 0010
      

      从上边的二进制表示中我们一下就看出,中英文统一步长的情况下,对于中文字符得到了充分利用,但是对于英文字符来说,存在大量的 0000 这种未利用空间,造成了内存浪费

    • 为了解决上边内存浪费的问题,就使用了 UTF-8UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。这里我们简单说一下 UTF-8 的规则:

      • 单字节的字符,字节的第一位设为 0,对于英语文本,UTF-8 码只占用一个字节,和 ASCII 码完全相同

      • n 个字节的字符(n>1),第一个字节的前 n 位设为 1,第 n+1 位设为 0,后面字节的前两位都设为 10,这 n 个字节的其余空位填充该字符 Unicode 码,高位用 0 补足

      比如:

      我:11100110 10001000 10010010
      是:11100110 10011000 10101111
      C:0100 0011
      o:0110 1111
      d:0110 0100
      e:0110 0101
      r:0111 0010
      
    • 对于 Swift 来说,String 是一系列字符的集合,也就意味着 String 中的每一个元素是不等长的。那也就意味着我们在进行内存移动的时候步长是不一样的,什么意思?比如我们有一个 Array 的数组(Int 类型),当我们遍历数组中的元素的时候,因为每个元素的内存大小是一致的,所以每次的偏移量就是 8 个字节

      但是对于字符串来说不一样,比如访问 str[1],那么是不是要把 "我" 这个字段遍历完成之后才能够确定 "是" 的偏移量?依次内推每一次都要重新遍历计算偏移量,这个时候无疑增加了很多的内存消耗。这就是为什么不能通过 Int 作为下标来去访问 String 的原因。

    • 源码中有关于 String 的 Index 布局描述: image.png

      • position aka encodedOffset:一个 48 bit 值,用来记录码位偏移量。

      • transcoded offset:一个 2 bit 的值,用来记录字符使用的码位数量。

      • grapheme cache:一个 6 bit 的值,用来记录下一个字符的边界。

      • reserved:7 bit 的预留字段 scalar aligned: 一个 1 bit 的值,用来记录标量是否已经对齐过。

    • 对于 String 的 Index 的本质是存储了 encodedOffset 和 transcoded offset。当我们构建 String 的 Index 的时候,其实是把 encodedOffset 和 transcoded offset 计算出来存放到 Index 的内存信息里面。而这个 Index 本身就是一个 64 位的位域信息

3、Array

3.1、Array源码分析

  • 初始化数组时会调用 _allocateUninitializedArray方法 image.png

  • 这个方法返回一个元组,通过 count 判断当前初始化的数组是否需要分配 缓冲区。我们来看当 count 大于 0 的情况,当大于 0 的时候,会调用一个 allocWithTailElems_1 方法,分配堆空间来存储数组当中的元素。接着通过 _adoptStorage 来创建数组 image.png 此时返回的这个元组里返回了 Array的bufferfirstElementAddress,这个 firstElementAddress 是数组第一个元素的地址;为什么要返回第一个元素的地址呢?是因为在第一个元素的地址之前,应该还有其它的信息 323e7c5d07a04b7b8b038b97e9cacc98~tplv-k3u1fbpfcp-watermark.image.png 所以, Array 里存储的其实是一个名为 _ContiguousArrayStorage 类的内存地址,而这个类存储这个存储着 元类型,引用计数,元素的个数,容量的大小和标志位,以及元素的内存地址 a89beadcf79a4a279b4057223bdf3f81~tplv-k3u1fbpfcp-watermark.image.png

3.2、Array追加数据

  • 当你向数组添加元素并且该数组开始超出其保留容量时,该数组会分配更大的内存区域并将其元素复制到新存储中;新存储是旧存储大小的倍数,触发重新分配的追加操作有性能成本,但随着阵列变大,它们发生的频率越来越低

  • 当数组 append 数据时

    @inlinable
    @_semantics("array.append_element")
    public mutating func append(_ newElement: __owned Element) {
        // Separating uniqueness check and capacity check allows hoisting the
        // uniqueness check out of a loop.
        _makeUniqueAndReserveCapacityIfNotUnique()
        let oldCount = _buffer.mutableCount
        _reserveCapacityAssumingUniqueBuffer(oldCount: oldCount)
        _appendElementAssumeUniqueAndCapacity(oldCount, newElement: newElement)
        _endMutation()
    }
    
    1. 首先它调用了一个_makeUniqueAndReserveCapacityIfNotUnique方法:

      @inlinable
      @_semantics("array.make_mutable")
      internal mutating func _makeUniqueAndReserveCapacityIfNotUnique() {
          if _slowPath(!_buffer.beginCOWMutation()) {
              _createNewBuffer(bufferIsUnique: false,
                               minimumCapacity: count + 1,
                               growForAppend: true)
          }
      }
      
    2. 如果缓冲区的存储是唯一引用的,将缓冲区置于可变状态,然后去创建新的 buffer。我们接着看这个 _createNewBuffer(bufferIsUnique:minimumCapacity:growForAppend) 的实现:

      @_alwaysEmitIntoClient
      @inline(never)
      @_semantics("optimize.sil.specialize.owned2guarantee.never")
      internal __consuming func _consumeAndCreateNew(bufferIsUnique: Bool, 
                                                     minimumCapacity: Int, 
                                                     growForAppend: Bool ) -> _ArrayBuffer {
          let newCapacity = _growArrayCapacity(oldCapacity: capacity,
                                               minimumCapacity: minimumCapacity,
                                               growForAppend: growForAppend)
          ...
      }
      
    3. 在这个方法当中它会去调用 _growArrayCapacity 方法来进行扩容: 16438071864864.jpg

    4. 所以,一个数组在进行扩容的时候,本质上是在旧的 capacity 上进行 2 倍扩容