从汇编角度看Swift的结构体和类

1,141 阅读7分钟

这里从特性、本质、区别来聊聊结构体和类,本文基于swift5

结构体

在Swift标准库中,绝大多数的公开类型都是结构体,而枚举和类只占很小一部分,比如Bool、Int、Double、String、Array、Dictionary等常见类型都是结构体

//Int
@frozen public struct Int : FixedWidthInteger, SignedInteger {
    ...
}

//String
@frozen public struct String {
    ...
}

//Array
@frozen public struct Array<Element> {
    ...
}

//Dictionary
@frozen public struct Dictionary<Key, Value> where Key : Hashable {
    ...
}

结构体的内存结构

通过下面代码打印结构体占用的字节

func testAgeStruct() {

    struct Age {
        var cat: Int
        var pig: Int
    }
    
    //占用内存大小
    print(MemoryLayout<Age>.size)
    //分配内存大小
    print(MemoryLayout<Age>.stride)
    //内存对齐
    print(MemoryLayout<Age>.alignment)
    
    let age = Age(cat: 1, pig: 2)
    print(age)
}

另外,可以通过选中age,右键->View Memory of "age"查看内存

内存地址为0x7ffeefbff4a0,内存中的内容为所在地址的16字节,如下图

结构体的初始化器

编译器会根据情况,为结构体生成多个初始化器,保证所有成员变量都有初始值,从而保证安全。

下面通过Age结构体来看看在各种情况下,编译器生成的初始化器。

  • 成员变量都没有初始值
struct Age {
    var cat: Int
    var pig: Int
}

var A1 = Age(cat: 5, pig: 6)
var A2 = Age(cat: 5)   //Missing argument for parameter 'pig' in call
var A3 = Age(pig: 6)   //Missing argument for parameter 'cat' in call
var A4 = Age()   //Missing arguments for parameters 'cat', 'pig' in call

因都cat、pig都没有初始值,编译器生成的是A1这种初始化器,需传入cat、pig,A2、A3、A4都会报错

  • 成员变量部分有初始值
struct Age {
    var cat: Int = 0
    var pig: Int
}

var A1 = Age(cat: 5, pig: 6)
var A2 = Age(cat: 5)     //Missing argument for parameter 'pig' in call
var A3 = Age(pig: 6)
var A4 = Age()    //Missing argument for parameter 'pig' in call

因cat有初始值,编译器生成的是A1、A3这种初始化器,所以A2、A4都会报错

  • 成员变量都有初始值
struct Age {
    var cat: Int = 0
    var pig: Int = 0
}

var A1 = Age(cat: 5, pig: 6)
var A2 = Age(cat: 5)
var A3 = Age(pig: 6)
var A4 = Age()

A1、A2、A3、A4都可以编译通过

注意:如果我们在定义结构体时,自定义了初始化器,那么编译器就不会再自动生成其他初始化器。

struct Age {
    var cat: Int = 0
    var pig: Int = 0
    
    init(cat: Int, pig: Int) {
        self.cat = cat
        self.pig = pig
    }
}

var A1 = Age(cat: 5, pig: 6)
var A2 = Age(cat: 5)
var A3 = Age(pig: 6)
var A4 = Age()

A2、A3、A4都会报错

初始化器的本质

下面通过汇编代码来看手写初始化器和编译器自动生成初始化器

  • 编译器自动生成初始化器

代码

testAgeStruct1
断点打在var a = Age(),运行起来,汇编代码断点落在init()
如下图,通过llbd的si指令进入init()方法,逐步查看里面的汇编代码。这里面通过mov指令将立即数$0x01、$0x02放到寄存器eax、edx中,然后可以通过finish指令结束init(),返回testAgeStruct1()方法中看,再将寄存器rax、edx中的给到内存中的地址为rbp-0x10,rbp-0x8的局部变量

  • 手动写初始化方法

代码

testAgeStruct2()方法的汇编代码

下面来看看手写init()方法的汇编代码
汇编代码跟上面编译器自动生成的初始化器是一样

通过以上两种情况汇编代码,我们可以看到,编译器生成的初始化器和我们手写效果是一样的。

初始化器

看看下面代码,会有什么结果?

func testAgeClass() {
    class Age {
        var cat: Int
        var pig: Int
    }
    let a1 = Age()

}

编译器会报以下错:

  • Class 'Age' has no initializers
  • 'Age' cannot be constructed because it has no accessible initializers

也就是说,与结构体不同,类中如果没有给初始化值,编译器是不会生成初始化器

再看看下面这段代码

func testAgeClass() {
    class Age {
        var cat: Int = 0
        var pig: Int = 0
    }
    let A1 = Age()
    let A2 = Age(cat: 1, pig: 2)  // 报错
    let A3 = Age(cat: 1)    //报错
    let A4 = Age(pig: 2)   //报错
}

上面代码,只有A1是编译器不报错,A2、A3、A4都会报错。如果是结构体,那么编译器就会生成上面四种初始化器,都能编译通过。 可以看出,与结构体不同,类中如果给初始化值,编译器只会生成无参的初始化器

下面我们再通过汇编看看下面代码,初始化器是怎么实现的

func testAgeClass() {
    class Age {
        var cat: Int = 1
        var pig: Int = 2
    }

    
    let a1 = Age()    //在此处打断点

}

testAgeClass()

初始化器的实现

在这里我们可以看到,_allocating_init()这个方法就是向堆空间申请内存的。在这里打个断点,通过si指令进去看看。通过这个alloc,我们也可以看出类是在堆空间中操作的。

这里我们继续进去init()方法中

在init()方法中,我们可以看到0x1这个立即数,会放到rax+0x10上。还有39行代码的,0x2放到rax+0x18上。至此,我们可以看出,类的初始化方法中执行了赋值操作。

结构体和类的本质区别

结构体是值类型,而类是引用类型

值类型在传递和赋值时进行复制,直接把所有内容拷贝一份,是深拷贝。

引用类型在赋值或给参数传参,是将内存地址拷贝一份,是浅拷贝。

下面从汇编角度分别看看结构体和类是在栈中还是堆空间申请内存的

func testAgeClass() {
    class Age {
        var cat: Int = 1
        var pig: Int = 2
    }


    let a1 = Age()

}

testAgeClass()

汇编

__allocating_init()
libswiftCore.dylib->swift_allocObject
libswiftCore.dylib->swift_slowAlloc
libsystem_malloc.dylib->malloc
按照以上汇编代码,逐层进入,最后可以进入到分配堆空间的libsystem_malloc.dylib文件中,由此可以看出类的内存分配是在堆空间中的。同时,也可以注意到,堆空间的分配一般跟alloc、malloc有关。

Swift中创建类的实例对象,向堆空间申请内存流程大致如上。

结构体

func testAgeClass() {
    struct Age {
        var cat: Int = 1
        var pig: Int = 2
    }


    let a1 = Age()

}

testAgeClass()

汇编代码

从汇编代码中看不到alloc、malloc这些方法,只看到0x1、0x2这两个立即数寻址,因此我们可以确定struct是在栈空间的

在内存中,值类型是在栈上进行存储和操作的。引用类型是在堆上进行存储和操作的。相比栈上的操作,从上面汇编代码,也可以看出,堆上的操作更加复杂和耗时。因此,苹果官方也是推荐使用结构体,以提高App的运行效率。

struct相比class,使用上有哪些优势?

更加安全

由于struct是结构较小,相比一个class的实例被引用多次,struct更加安全

无须担心内存泄漏问题

struct是值类型,是在栈空间上,由系统自动管理内存,避免了内存泄漏等问题

自动生成带参数初始化器

struct中自动生成带参数初始化器,比class中要手动去生成方便不少

Copy-on-Write

Copy-on-Write是一种用来优化占用内存大的值类型的拷贝操作的机制。

  • 对于Int、Bool等基本类型的值类型,它们在赋值的时候就会发生拷贝,它们没有Copy-on-Write这一特性
  • 对于String、Array、Dictionary、Set类型,当它们赋值的时候不会发生拷贝,只有在修改的之后才会发生拷贝,即Copy-on-Write。

Copy-on-Write技术使得struct中的内存使用效率更高。

建议:不需要修改的变量,建议定义为let。这样后面不小心修改了它,编译时会报错提醒。

最后:因个人水平有限,如有不对的地方,还望大神提点。如若对你有一些帮助,能给个赞,那就更感激不尽 😄