从零学习Swift 07:属性

2020-04-28  本文已影响0人  小心韩国人
总结

Swift 中的属性分为两大类:存储属性 , 计算属性

一: 存储属性

存储属性类似于成员变量,定义方式很简单:

//存储属性
class Person{
    var name: String = "张三"
}

存储属性存储在成员变量中;结构体Struct和类Class都可以定义存储属性,唯独枚举不可以.因为枚举变量的内存中只用于存储case 值和关联值,没有用来存储存储属性的内存.

另外需要注意的是,在初始化类和结构体的实例时,必须为所有的存储属性设置初始值.

二: 计算属性

计算属性的定义方式需要用到set , get关键字:

//计算属性
var age: Int{
  set{
    ...
  }
  get{
    ...
  }
}

像上面的age就是一个计算属性.为了更生动的理解计算属性,我们以游戏账号升级为例子.假如规则是这样:在线两个小时,游戏账号升级一级:


//计算属性
struct Game{
    
    //游戏时长,单位 : 小时
    var time: Int
    
    //游戏等级,在线两个小时升一级
    var grade: Int {
        set{
            time = newValue * 2
        }
        get{
            time / 2
        }
    }
}
//初始化 游戏时长为 2 个小时
var game1 = Game(time: 2)
print("在线 \(game1.time) 个小时, 游戏等级为 \(game1.grade) 级")
//设置游戏等级为 30 级
game1.grade = 30
print("在线 \(game1.time) 个小时, 游戏等级为 \(game1.grade) 级")

计算属性的本质就是方法,这一点我们可以通过汇编直接看出来:

调用计算属性的 set 方法

既然计算属性的本质是方法,就说明计算属性是不会占用实例变量的内存.因为枚举,结构体,类中都可以定义方法,所以同样也可以定义计算属性.需要注意的是,计算属性只能用var,不能用let.因为计算属性是会变化的.

枚举rawValue的本质

我们之前在从零学习Swift 02:枚举和可选项 中说过,枚举是不会存储原始值的,今天我们就来搞清楚枚举rawValue的本质.
首先看一下系统默认的rawValue:

系统默认

我们可以写个函数,实现rawValue的功能:

自定义 rawValue 函数

但是很奇怪,为什么系统的rawValue没有括号(),其实它是计算属性

rawValue 的本质就是 只读的 计算属性

现在我们就搞清楚了rawValue的本质其实就是只读的,计算属性

另外我们也发现,计算属性可以只有get没有set,那可不可以只有set,没有get呢?不可以,编译器会直接报错.

有set必须要有get
三: 延迟存储属性

使用lazy可以定义一个延迟存储属性,在第一次使用属性的时候才会初始化.类似于 OC 中的懒加载.

如上图只创建了person,还没有使用car,car就已经初始化了.

我们在car前面加上lazy关键字:

延迟存储属性
  1. lazy属性必须是var,不能是let.因为Swift规定let必须在实例初始化方法完成之前就有值.而lazy是用到的时候才初始化,这就冲突了.
  2. lazy属性不是线程安全的,多个线程同时访问同一个lazy属性,可能不止加载一次.
  3. 当结构体包含一个延迟存储属性时,只有var修饰的实例才能访问延迟存储属性,let修饰的实例不允许访问延迟存储属性,什么意思呢?看下面一张图就知道了:
    结构体 let 实例
四: 属性观察器

属性观察器类似于 OC 中的 KVO,它的定义方式如下:

属性观察器

使用属性观察器必须满足三个条件:

  1. 必须是非lazy修饰(因为 lazy 属性是在第一次访问属性的时候才创建的,而添加属性观察器可能会打破 lazy 的机制)
  2. 必须是var变量(既然是属性观察器,肯定是观察属性值的变化,如果用 let 常量就没有任何意义了)
  3. 必须是存储属性(因为计算属性内部本来就有一个set,可以把监听代码写到set中.)

思考一下为什么计算属性不能设置属性观察器?
因为计算属性内部本来就有一个set ,可以把监听代码写到set中.

五: inout 参数

之前在从零学习Swift 01:了解基础语法中用汇编分析过inout参数,知道inout输入输出参数是引用传递.今天使用更复杂的类型更深入的研究inout参数的本质.

示例代码:


//矩形
struct Rectangle{
    //存储属性  长
    var length: Int
    //属性观察器  宽
    var width: Int{
        willSet{
            print("newValue : ",newValue)
        }
        
        didSet{
            print("newValue : \(width) , oldValue : \(oldValue)")
        }
    }
    
    //计算属性 (计算属性不占用实例内存空间,本质是方法)
    //面积
    var area: Int{
        set{
            length = newValue / width
        }
        
        get{
            return length * width
        }
    }
    
    func show(){
        print("长方形的长 length = \(length) , 宽 width = \(width) , 面积 area = \(area)")
    }
}

var rect = Rectangle(length: 10, width: 4)
rect.show()


func test(_ num: inout Int){
    num = 20
}

test(&rect.length)
rect.show()

上面代码结构体中分别有存储属性,属性观察器,计算属性.下面我们就分别把这三种属性传入inout参数.

  1. inout参数之存储属性

分析汇编:


从上图可以看到,调用test()时直接把全局变量rect的地址作为参数传入进去.为什么不是把length的地址传进去呢?因为length是结构体的第一个成员,所以结构体的地址就是length的地址.这里传入rect的地址和length地址是等价的.

  1. inout参数之计算属性

分析汇编:


从汇编语言中可以看到,当inout参数传入的是计算属性时,在调用test()方法之前会先调用计算属性的getter方法取出值,并且把值存入栈空间;然后再调用test()方法,并且把栈空间的地址作为参数传递进去.所以在test()方法内部修改的是栈空间的值;最后再调用计算属性的setter方法,从栈空间中取出值传入setter方法,并赋值.

图解:


图解

3.inout参数之属性观察器

分析汇编:

从上图的汇编中可以看到,inout参数是属性观察器时,内部逻辑和计算属性很相似,都是取出值放到栈空间,然后修改栈空间的值.

为什么属性观察器不能像存储属性那样,直接传入地址,直接修改呢?因为属性观察器涉及到监听的逻辑.我们看看第三步的setter方法的汇编:

setter 方法

可以看到setter方法内部会调用willSet , didSet,并且在willSet调用完之后才真正赋值.

属性观察器之所以要这么设计就是因为要调用willSetdidSet.达到监听属性改变的效果.因为inout参数就是引用传递.如果直接把width的地址传给test(),test内部就直接修改了width的值.willSetdidSet根本就不会触发.

现在我们总结一下inout:
  1. inout本质就是引用传递
  2. inout参数是计算属性或者设置了属性观察器的存储属性时,采取了copy in , copy out的做法:
    2.1: 调用函数时先复制参数的值,产生副本 ( copy in )
    2.2: 将副本的内存地址传入函数,在函数内修改的是副本的值
    2.3: 将副本的值取出来,覆盖实参的值( copy out )
六: 类型属性

上面讲的属性都是实例属性,通过实例访问的.Swift 中还有通过类型访问的属性--类型属性.
类型属性通过static关键字定义;如果是类,也可以通过class关键字定义.


//类型属性
struct Person{
    static var age: Int = 1
}
Person.age = 10

类型属性的本质就是全局变量,在整个程序运行过程中,只有1份内存.
我们用汇编看一下以下代码num1 , age , num2的内存地址:

var num1 = 10
struct Person{
    static var age: Int = 1
}
Person.age = 11
var num2 = 12


汇编如下:

连续的内存地址

会发现num1 , age , num2三个变量的地址都是连续的,说明他们都在全局区.

类型属性还有个很重要的特性:类型属性默认是 lazy , 在第一次使用的时候才会初始化, 并且是线程安全的,只会初始化一次.

前面我们讲延迟存储属性 lazy 关键字时说过,lazy不是线程安全的.为什么类型属性默认是lazy,它为什么是线程安全的呢?

因为它的内部会调用swift_once``dispatch_once_f

下面我们通过汇编证明一下,首先断点打到类型属性初始化的部分,看看类型属性初始化的函数地址.

断点位置

然后运行程序,看到类型属性初始化函数地址为:

类型属性初始化函数地址

接着把断点调整到如图所示位置:

运行代码,分析汇编如下:

第一步

进入函数:

第二步

进入swift_once :

swift_once 函数内部

会发现swift_once内部会调用dispatch_once_f.

所以现在就能明白为什么类型属性是线程安全的了,因为它的初始化代码放到dispatch_once_f中调用的.

上一篇 下一篇

猜你喜欢

热点阅读