swift进阶四:懒加载 & 单例 & Struct

swift进阶 学习大纲

上一节,我们分析了属性(存储性、计算型)和属性观察者(willSet、didSet)

  1. Lazy 懒加载
  2. static 单例
  3. struct 结构体
  4. mutating & inout
  5. 静态函数调用
  6. 函数重载
  7. 静态寻址

准备工作:

  1. MachoView软件: 下载地址
    MachoView是查看机器执行文件的工具。苹果的应用经过LLVM编译处理后,会输出Mach-O格式(全称Mach Object)的可执行文件。在这个文件中,我们可以查看APP运行需要的代码资源和执行指令。

1. Lazy 懒加载

1.1 创建

swift中懒加载是使用Lazy进行修饰

  • 必须是var(可变存储属性),不可以是let(不可变属性),也不能是option(可选值)。
class HTPerson {
    // 懒加载属性
    lazy var name: String = "ht"
}
  • 初始时,没有值。

    image.png

  • 首次访问后,有值

    image.png

  • 所以Lazy修饰的属性,具备延时加载功能。(首次访问时才加载)

1.2 大小

  • 懒加载属性的大小,与本身属性大小不同:
    swift中int(64位系统)原本8字节,但lazy修饰后,就变成16字节
    image.png

1.3 SIL分析

  • 将main.swift输出SIL文件,使用VSCode打开SIL文件:
swiftc -emit-sil main.swift >> ./main.sil
image.png
  • 可以清晰看到:懒加载属性,创建时,是可选值。但是在首次访问(getter)时,进行初始赋值,返回非可选类型的值。

注意
懒加载是线程不安全的。 读写未加锁,多线程同时访问(getter)时,可能多次赋值。

Q: 为何lazy修饰的Int属性是16字节:

  • 因为lazy修饰的属性,会变成可选类型。
    (option: 可选类型。本质是枚举,值类型)
    包含some<Int>和none两个枚举类型。其中none是0x0。打印
    image.png
  • 其中:none占1字节,some<Int>占8字节。所以实际大小(size)为9字节。
  • 对外遵循align8(8字节对齐)原则,系统会开辟16字节空间(8的倍数)来存储真实大小为9字节的数据
    (align8原则:为了避免五花八门的空间大小,增加系统读取数据的困难性。所以统一以8字节为一个单位,进行一段一段的截取,提高读取效率。)

lazy总结

  • lazy必须修饰var(可变类型)存储属性,
  • 必须有默认初始值,但初始值会延迟到首次加载时赋值。
    (所以lazy修饰的属性,叫延迟存储属性,也叫懒加载属性)
  • 延迟存储属性是线程不安全的(可能多次赋值)
  • 延迟存储属性影响实例对象的大小

2. static 单例

2. 1 类属性

  • 类属性使用static修饰
class HTPerson {
   static var age: Int = 18
}
print(HTPerson.age) // 打印18
print("end")
  • 生成SIL文件,getter方法调用了builtin "once",内部是调用swift_once:
    image.png
  • swift源码查看swift_once,内部调用gcd的dispatch_once_f,创建单例,线程安全(内部有锁,读写安全)。
    image.png

2.2 OC & swift 单例

  • OC单例:
    使用gcd创建,使用父类alloc初始化,拦截alloc,任何方式实例化,返回的都是单例对象。
@implementation HTPerson

static HTPerson *sharedInstance = nil;

+ (instancetype)sharedInstance{
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 不使用alloc方法,而是调用[[super allocWithZone:NULL] init]
        // 重载allocWithZone基本的对象分配方法,所以要借用父类(NSObject)的功能处理底层内存分配
        // 此时不管外部使用设么方式创建对象,最终返回的都是单例对象
        sharedInstance = [[super allocWithZone:NULL] init] ;
    });
    return sharedInstance;
}

+(id)allocWithZone:(struct _NSZone *)zone {
    return [HTPerson sharedInstance] ;
}
 
-(id)copyWithZone:(NSZone *)zone {
    return [HTPerson sharedInstance] ;
}
 
-(id)mutablecopyWithZone:(NSZone *)zone {
    return [HTPerson sharedInstance] ;
}

@end
  • Swift单例:
    直接static创建,将init方法藏起来(private私有重写)。
class HTPerson {
    // 创建单例对象
    static let sharedInstance = HTPerson()
    // 重写init方法,设为私有方法
    private init(){}
}

3. struct 结构体

对比struct和class:

  1. init初始化方法
    struct: 没有init时,默认生成。创建init后,使用自己创建的。
    class: 必须手动创建
    image.png
  1. 类型:
    struct: 值类型 (不可更改,分配在栈区。copy是值拷贝(深拷贝),不共享状态)
    class: 引用类型 (可更改,分配在堆区。copy是指针拷贝(浅拷贝),共享状态)
    image.png
  • 值类型是直接存储值,所以读取和拷贝都是值。
  • 引用类型是存储指针地址,所以读取和拷贝都是指针地址。
    (需要通过指针地址去取值)

检验:(withUnsafeMutablePointer函数读取对象的指针地址)

image.png

struct实例化时,是栈区alloc_stack使用let创建并返回self。(没有看到malloc相关函数,没有在堆区开辟空间)。读取值是通过栈区地址偏移,直接读取。

  1. 写时复制(Copy On Write)
  • 当struct对象赋值给一个新对象时,2个对象指向的地址是同一个。
  • 只有当新对象被引用时,才会在新的内存空间,完整拷贝一个对象,并存储新值。
    目的: 提升性能,节约内存空间
    (2个完全一样的对象,没必要占用2个内存空间,当它真正被使用时,再开辟空间)

struct值类型中,应避免包含引用类型对象。因为保存的是引用类型的地址,同样会调用strong_retain对引用类型对象进行引用计数+1。我们默认struct内部都使用值类型,减少使用的困扰
(可使用CFGetRetainCount()打印引用计数,进行观察)

4. mutating & inout

  • struct 属性不可修改,除非有mutating声明。
struct HTStack {
    var items = [Int]()
    // 使用 mutating 修饰函数
    mutating func push(item: Int) {
        self.items.append(item)
    }
}
image.png
  • 生成SIL文件,可以查看到mutating修饰的函数,内部使用@inout声明了入参,读取的是入参地址,而不是值。所以可以更改。
    image.png

5. 静态函数调用

struct是值类型,属性是直接读取,那它怎么调用函数呢?

  • 创建测试项目,编译生成Demo(.o可执行文件):

    image.png

  • 打开终端,输入nm 空格,拖入.o可执行文件读取完整路径,回车。
    可以看到eat函数编译后的符号名(_$s4Demo8HTPersonV3eatyyF)。

    image.png

  • 使用machoView软件,打开编译好的.o文件,点击Assembly,搜索eat,比对函数名,定位eat函数在符号表中的位置:

    image.png

  • 选择Symbol Table下的Symbol符号表,搜索eat,比对Value,确实可通过Assembly中记录的地址找到eat函数在符号表中的位置。而String Table Index记录了该符号在字符串表中的位置。(第2位开始)

    image.png

  • 选择String Table字符串表,核对可发现,从第2个字符开始,就是eat函数命名重整后的符号名:_$s4Demo8HTPersonV3eatyyF

    image.png

  • 总结:
  • 项目编译后,machoView 查看.o文件。
  • c语言函数和结构体函数都是静态调用(直接调用函数地址)
  • 静态函数的调用都在__TEXT段中,
  • __TEXT中:记录符号在Symbol符号表中的位置
  • Symbol符号表中:记录符号在StringTable字符串表中的位置
  • StringTable字符串表:以字符串形式,存储所有变量名和函数名

静态执行,执行效率非常高!(静态调用,就是地址调用)

DSYM文件: 用于还原符号表。捕获崩溃,定位线上BUG

  • 选择release模式,编译,会多生成DSYM文件,使用MachoView查看.O文件。发现找不到eat函数,比Debug模式下少很多内容。

重点

  • 静态链接函数是不需要符号的,一旦地址确定,可直接调用。

  • 在Debug调试环节可以便于开发,但release模式下(iOS项目),为了减小包体积,strip(剥掉)这些静态链接函数的符号。

  • release包中存在的符号,是不能直接确定地址的。
    如:Lazy Symbol Pointers(懒加载的符号):在首次被调用时才会生成地址,所以不能strip掉。
    再比如:外部库的函数调用:print。 运行时断点,在汇编中可以看到,调用了dyld_stub_binder。 因为print函数不在当前函数库中,编译文件记录了print函数所在的库,并进行了动态绑定。 在调用这个函数时,需要沿着这个路径去找库,找到它真正的地址。再进行调用。

  • 静态链接函数的名称和地址,在编译期就已经确定,可优化,直接使用地址。
    (可多次编译查看,会发现代码没改变时,名称和地址都是不会变的)
    而动态库的链接,是dyld在运行时去动态查找的,无法直接确定地址。所以需要符号表记录它存放在哪个库的哪个地址,再顺应摸瓜去找到并调用它。

6. 函数重载

函数重载: 使用相同的函数名,但入参不一样的函数。

struct HTPerson {
    // 函数名都是eat,但参数类型不一样。
    func eat(num: Int){
        print("吃\(num)个")
    }
    
    func eat(name: String) {
        print("吃\(name)")
    }
}

Q: 为什么C、OC语言不支持函数重载,而C++、swift支持函数重载?

区别: 是否有命名重整规则。C和OC没有,C++和swift有。

  • 通过打印MachO可执行文件的符号表。就清楚了:

【方法】 打开终端,输入nm,拖入MachO可执行文件读取完整路径,回车:

  • 我们以各类语言的test函数为例:
func test() { }

【C语言】函数名:_test

image.png

【OC语言】函数名:-[HTPerson test]

image.png

【C++语言】函数名:__Z4testv

image.png

【Swift语言】函数名:_$s4Demo4testyyF

image.png

这就是C++、swift支持函数重载,而C、OC语言不支持的原因。

  • 因为他们函数符号(名称)没处理,相同命名函数,无法区分。

拓展
可以通过xcrun swift-demangle s4Demo4testyyF ,将swift的test函数符号名还原:
(其中s4Demo4testyyF是命名重整后的test函数)

image.png

7. 静态寻址

  • 新建一个iOS项目,以test函数为例,在调用test函数处加断点。

    image.png

  • 运行代码,打开汇编模式,可以看到test函数在运行时的调用地址为0x10cbd51e0

    image.png

  • 编译后,用MachoView中打开.o文件,在__TEXT中搜索tes,找到编译后的test函数地址: 0x1000041D0

    image.png

发现test函数调用地址在编译期和运行时有偏差(ASLR随机地址偏移)。

ASLR:随机地址偏移

  • 为保证APP的数据安全,每次APP启动时,都会随机生成一个地址偏移值。
    运行时查找所有符号,必须在编译期确定的符号地址上,加上随机生成的ASLR偏移值,才是运行时正确的符号地址。

验证

  • 公式:

    1. ASLR随机偏移值 = 运行时基地址 - 编译期基地址
    2. 运行时函数地址 = 编译期函数地址 + ASLR随机偏移值
  • 获取信息:(每次APP启动,运行时的数据都会变)
    程序运行到断点,输入image list打印镜像文件的地址。第一个镜像文件地址就是运行时的基地址。
    【运行时基地址】:0x000000010897f000
    【运行时函数地址】0x1089831d0

    image.png

【编译期函数地址】0x1000041D0

image.png

【编译期基地址】0x100000000(Load Comand的_TEXT中找到VM Address)

image.png

【计算】:

  1. ASLR偏移值 = 【运行时基地址】:0x000000010897f000 - 【编译期基地址】0x100000000 = 0x0x000000000897f000

    image.png

  2. 运行时函数地址 = 【编译期函数地址】0x1000041D0 + ASLR偏移值0x0x000000000897f000 = 0x00000001089831d0

    image.png

与我们打印的【运行时函数地址】0x1089831d0 一抹抹一样样。完美!!

  • 至此。我相信你对ASLR随机偏移值,静态寻址都十分熟悉了。
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容