专注、坚持

Swift 拾遗 - struct & class

2020.08.06 by kingcos
Date Notes
2020-08-06 首次提交

Preface

《Swift 拾遗》是一个关于 Swift 的新文章专辑,这个系列的文章将不会涉及基本语法的层面,而是尝试从底层「拾」起之前所忽视的内容。今天我们将一起简单探究 Swift 中的枚举 structclass。"

结构体

Swift 中的结构体被广泛使用,这是因为它和枚举都是值类型,在操作时会更加安全。我们这里定义一个简单的结构体:

struct Foo {
    var a = 1
    var b = 1
}

var foo = Foo() // Breakpoint 🔴
withUnsafeMutablePointer(to: &foo) { print("\($0)") } // 0x00000001000030d8

print(MemoryLayout<Foo>.size)      // 16
print(MemoryLayout<Foo>.stride)    // 16
print(MemoryLayout<Foo>.alignment) // 8

// (lldb) memory read 0x00000001000030d8
// 0x1000030d8: 01 00 00 00 00 00 00 00 01 00 00 00 00 00 00 00  ................
// 0x1000030e8: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

我们可以从以上的内存地址和对应的值中看出,结构体变量的地址就是结构体中第一个成员的地址,结构体中所有成员的地址是连续的;结构体变量占用内存的大小受其成员的影响。

再来一个更加复杂的嵌套类型的例子:

// ...

print(MemoryLayout<Int>.stride)     // 8
print(MemoryLayout<Bool>.stride)    // 1
print(MemoryLayout<Double>.stride)  // 8
print(MemoryLayout<Foo>.stride)     // 16

struct Bar {
    var a: Int
    var b: Bool
    var c: Bool
    var d: Int
    var e: Foo
}

var bar = Bar(a: 10, b: true, c: true, d: 11, e: Foo(a: 11, b: 12))
var baz = 20

print(MemoryLayout<Bar>.size)      // 40
print(MemoryLayout<Bar>.stride)    // 40
print(MemoryLayout<Bar>.alignment) // 8

// (lldb) po withUnsafeMutablePointer(to: &bar) { print("\($0)") }
// 0x0000000100004290
// (lldb) x/6xg 0x0000000100004290
// 0x100004290: 0x000000000000000a 0x0000000000000101
// 0x1000042a0: 0x000000000000000b 0x000000000000000b
// 0x1000042b0: 0x000000000000000c 0x0000000000000014

此时虽然 Bar 结构体中嵌套了 Foo 结构体,但其仍按照 8 个字节进行内存对齐;其中连续的两个 Bool 类型的变量共同占用一份 8 个字节的内存。

Swift 中的类不同于结构体和枚举,是引用类型,即初始化后会在堆上开辟空间存储类中的变量,并将首地址通过栈上的指针变量引用。

当函数调用时,将会在栈上开辟一块内存空间供函数使用。为了更加直观,我们在函数中分别创建类和结构体的变量并初始化:

class Foo {
    var a = 1
    var b = 2
}

struct Bar {
    var a = 3
}

func test() {
    var foo = Foo()
    var bar = Bar()
    var baz = 4

    withUnsafeMutablePointer(to: &foo) { print("\($0)") } // 0x00007ffeefbff4a8
    withUnsafeMutablePointer(to: &bar) { print("\($0)") } // 0x00007ffeefbff4a0
    withUnsafeMutablePointer(to: &baz) { print("\($0)") } // 0x00007ffeefbff498
}

print(MemoryLayout<Foo>.size)      // 8
print(MemoryLayout<Foo>.stride)    // 8
print(MemoryLayout<Foo>.alignment) // 8

test()

test 函数的栈空间中,内存地址从高地址开始分配,因此 foo 首地址位于 0x00007ffeefbff4a8,而 bar 位于 0x00007ffeefbff4a0baz 位于 0x00007ffeefbff498,它们的地址差值均为 8,即占用 8 个字节:

(lldb) p 0x00007ffeefbff4a8 - 0x00007ffeefbff4a0
(Int) $R0 = 8
(lldb) p 0x00007ffeefbff4a0 - 0x00007ffeefbff498
(Int) $R2 = 8

接下来我们尝试使用 LLDB 中的 memory readx 命令,查看这些变量对应内存地址中的内容:

(lldb) x --size 8 --format x --count 4 0x00007ffeefbff498
0x7ffeefbff498: 0x0000000000000001 0x0000000000000003
0x7ffeefbff4a8: 0x00000001005052a0 0x0000000000000000

其中只有 0x7ffeefbff4a8foo 中的内容有些不同,其中并没有直接存储类中变量的值,而是一段堆空间的内存地址。

如何确定分配堆空间

内存可以被分为很多不同的区域,其中栈空间无需开发者手动申请和回收,比如函数调用时栈空间系统自动分配,而当函数返回时又会被系统自动回收;而堆空间需要手动开辟和回收。在 C/C++ 中,通常在调用 alloc / malloc 函数时将会在堆上开辟一段内存空间。在 Swift 中我们也可以观察是否调用了 malloc 函数来判断构造方法到底有没有在堆上开辟内存空间。我们在 var foo = Foo() 一行打个断点,并 Xcode Menu - Debug - Debug Workflow - Always Show Disassembly:

demo`test():
    ; ...
->  0x1000011eb <+43>:  movq   %rdx, %rdi
    ; ...
    0x100001206 <+70>:  callq  0x100001050               ; demo.Foo.__allocating_init() -> demo.Foo at main.swift:1
    ; ...

跳转到 0x100001206 一行并 si(Step Into):

demo`Foo.__allocating_init():
->  0x100001050 <+0>:  pushq  %rbp
    ; ...
    0x100001064 <+20>: callq  0x100001c4e               ; symbol stub for: swift_allocObject
    ; ...

跳转到 0x100001064 一行并 si

demo`swift_allocObject:
->  0x100001c4e <+0>: jmpq   *0x13ec(%rip)             ; (void *)0x0000000100001cec

从此处连续 si 多次直到进入 libdyld.dylib`dyld_stub_binder: 中,跳转至最后一行并si

libswiftCore.dylib`swift_allocObject:
->  0x7fff6df5dd00 <+0>:  pushq  %rbp
    ; ...
    0x7fff6df5dd22 <+34>: callq  0x7fff6df5dc90            ; swift_slowAlloc
    ; ...

跳转到 0x7fff6df5dd22 一行并 si

libswiftCore.dylib`swift_slowAlloc:
->  0x7fff6df5dc90 <+0>:  pushq  %rbp
    ; ...
    0x7fff6df5dca4 <+20>: callq  0x7fff6dfda28c            ; symbol stub for: malloc
    ; ...

此时我们就能看到符号桩 malloc,也可以进一步 si 直到进入 libsystem_malloc.dylib`malloc:,Obj-C 中的 alloc 其实也来自这里,这也验证了类的对象分配在堆空间。关于 malloc 可详见《iOS 中的 NSObject》一文。

我们继续查看这个内存地址中的内容:

(lldb) x --size 8 --format x --count 4 0x00000001005052a0
0x1005052a0: 0x00000001000031a8 0x0000000000000002
0x1005052b0: 0x0000000000000001 0x0000000000000002

虽然 Foo 类型的变量只占用 8 个字节,这是因为在 64 位下,指针存储的内存地址占用 8 个字节。而指针指向的内存地址,即对象实际占用的内存空间这里我们可以看出一共占用了 32 个字节,其中的内容如下表:

内存地址 内容 信息
0x1005052a0 0x00000001000031a8 指向类型信息
0x1005052a8 0x0000000000000002 引用计数
0x1005052b0 0x0000000000000001 bar.a
0x1005052b8 0x0000000000000002 bar.b

我们也可以通过 Foundation 中的 malloc_size 函数获取对象实际被分配的内存大小:

import Foundation

// ...

var foo = Foo()

// Swift / Obj-C 中的对象将按照 16 的倍数进行对齐
print(malloc_size(Unmanaged.passUnretained(foo).toOpaque())) // 32

// class_getInstanceSize 并非对象实际被分配的内存大小
print(class_getInstanceSize(Foo.self))                       // 32
print(class_getInstanceSize(type(of: foo)))                  // 32

Reference