09、Swift中的class

1、struct 和 class的差异

作为Swift中的另外一种自定义类型,从语法上来说,class和struct有很多相似的地方,它们都可以用来自定义类型、都可以有properties,也都可以有methods。
作为Swift中的引用类型,class表达的是一个具有明确生命周期的对象,我们需要关注这类内容的“生死存亡”,而值类型,我们更多关注的,就真的只是它的值而已。

1.1 引用类型必须明确指定init方法

首先,Swift并不会为class自动生成默认的init方法。如果我们不定义它,Swift编译器会报错。

1.2 引用类型关注的是对象本身

其次,class和struct对“常量”的理解是不同的。我们分别定义一个PointRef和PointValue的常量:

let p1 = PointRef(x: 0, y: 0)
let p2 = PointValue(x: 0, y: 0)

同样是常量,当我们修改p2的属性时,编译器会报错:p2 is a let constant:

p2.x = 10 // Compile time error

但是,我们却可以修改p1:

p1.x = 10 // OK

这是因为,p2作为一个值类型,常量的意义当然是:“它的值不能被改变”。但是p1作为一个引用类型,常量的意义则变成了,它可以修改自身的属性,但不能再引用其他的PointRef对象。如果我们尝试让p1引用另外一个PointRef对象,就会发生下面的错误:

p1 = PointRef(x: 1, y: 1) // Compile time error

以上就是引用类型代表的“对象”和值类型代表的“值本身”在语义上的差别。而这种差别,还体现在了对它们各自进行赋值之后的表现上:

var p3 = p1
var p4 = p2

这之后,当我们使用===比较p1和p3的时候,得到的结果是true:

p1 === p3 // true

并且,当我们修改了p3之后,p1的值,会一并修改:

p3.x = 10
p1.x // 10

但是,当我们修改了一个值类型时,却并不会这样:

p4.x = 10
p2.x // 0

1.3 引用类型默认是可以修改的

由于引用类型关注的是其引用的对象,而不是对象的值。因此,它的方法默认是可以修改对象属性的。例如:

class PointRef {
    // ...

    func move(to: PointRef) {
        self.x = to.x
        self.y = to.y
    }
}

但是,对于值类型PointValue来说move必须用mutating来修饰:

struct PointValue {
    // ...

    mutating func move(to: PointValue) {
        self.x = to.x
        self.y = to.y
    }
}

所以,修改一个struct的本意,实际上是你需要一个全新的值。
最后,还有一点要说明的是,在PointValue里,我们可以直接给self赋值:

mutating func move(to: PointValue) {
    self = to
}

编译器知道对一个值类型赋值就是简单的内存拷贝,因此,他会自动用to的每一个属性设置self的对应属性。但是,对于一个引用类型来说,你却不能这样:

class PointRef {
    // ...
    func move(to: PointRef) {
        self = to // !! Compile time error !!
    }
}

在class的方法里,self自身是一个常量,我们不能直接让它引用其它的对象。

2、理解class类型的各种init方法

2.1 默认init

2.1.1 方式一

给每一个属性都添加默认值

class Point2D {
    var x: Double = 0
    var y: Double = 0
}

调用
let origin = Point2D()

2.1.2 方式二

通常,我们还是至少会为class添加一个memberwise init方法。哪怕它就是一个逐个属性赋值的方法。

class Point2D {
    var x: Double
    var y: Double
    
    init(x: Double = 0, y: Double = 0) {
        self.x = x
        self.y = y
    }
}
为了让一个对象可以默认构造,class必须提供一个不需要参数的init方法,并且,这个方法必须初始化class的每一个属性。

在Swift里,这种真正初始化class属性的init方法,叫designated init,它们必须定义在class内部,而不能定义在extension里,否则会导致编译错误。

另外,除了designated init方法之外,还有一类不真正初始化class属性的方法。

2.2 Convenience init

extension Point2D {
    convenience init(at:(Double,Double)) {
        self.init(x:at.0,y:at.1)
    }
}

let point = Point2D(at: (2.0,2.0))
这时,我们就需要把作为参数的(2.0, 2.0)拆开成Point2D的每一个属性,然后调用designated init。对于完成这类任务的init方法,就叫做convenience init

可以看到,对于convenience init来说,它有两个要素:

  • 使用convienience关键字修饰;
  • 必须最终调用designated init完成对象的初始化;如果我们直接在convenience init中设置self.x或self.y,会导致编译错误;

2.3 Failable init

例如,我们希望用一个String tuple初始化Point2D:

let point44 = Point2D(at: ("4.0", "4.0"))

参考之前的convenience init,我们可以如法炮制一个:

class Point2D {
    // ...
    convenience init?(at: (String, String)) {
        guard let x = Double(at.0),
            let y = Double(at.1) else { 
                return nil 
            }
        
        self.init(at: (x, y))
    }
}

这次,由于String tuple版本的init有可能失败,我们需要用init?的形式来定义它。在它的实现里,如果参数中的String无法转换成Double,我们就返回nil,表示构建失败。否则,就调用Double tuple版本的convenience init最终完成对象的创建。
这里,我们只要保证最终可以调用到designated init方法就好了,而不一定要在convenience init方法中,直接调用deignated init方法。
另外要说明的一点是,一个failable designated init方法不能被non failable convenience init调用。但是,一个普通的designated init方法,却可以被failable convenience init调用。

3、继承

3.1 init方法

3.1.1 init的继承

默认情况下,派生类不从基类继承任何init方法。基类的init方法只在有限条件下被派生类自动继承。

(1)如果派生类没有定义任何designated initializer,那么它将自动继承继承所有基类的designated initializer。
(2)如果一个派生类继承了所有基类的designated init,那么它也将自动继承基类所有的convenience init。

class Point2D {
    var x: Double
    var y: Double
    
    init(x: Double = 0, y: Double = 0) {
        self.x = x
        self.y = y
    }
}

extension Point2D {
    convenience init(at:(Double,Double)) {
        self.init(x:at.0,y:at.1)
    }
}

class Point3D : Point2D {
    var z:Double = 0.0
}
//Point3D自动继承了Point2D的designated init和convenience init。
let point3 = Point3D(at: (0, 0))

3.1.2 重载init方法

首先,为了可以在初始化时自定义self.z,我们必须自己编写一个designated init,这样,我们也就不用在定义的时候给它设置初始值了:

class Point3D: Point2D {
    var z: Double
    
    init(x: Double = 0, y: Double = 0, z: Double = 0) {
        self.z = z
        super.init(x: x, y: y)
    }
}

然后,我们之前所有自动从Point2D继承而来的init就都不存在了。在派生类中自定义designated init,表示我们要明确控制派生类对象的构建过程。
但是,我们仍有办法让Point3D从Point2D继承所有的convenience init,只要我们在Point3D中手工实现所有Point2D的designated init方法就好了。

这里,还有一点要特别说明下,在派生类中,重载基类的convenience init方法,是不需要override关键字的。

3.1.2 init的初始化过程

派生类的初始化过程分成了两个阶段:
阶段一:从派生类到基类,自下而上让类的每一个属性都有初始值。
因此,如果你回头去看Point3D定义的designated init方法就会发现,我们一定是先初始化self.z,然后再调用super.init初始化基类的成员。如果你把这个顺序倒过来,就会发生编译错误。
阶段二:所有属性都有初始值之后,从基类到派生类,自上而下对类的每个属性进行进一步加工
当class中所有属性都有初始值之后,我们才可以调用其它方法或库函数进一步对属性的值进行加工。

class Point3D: Point2D {
    // ...
    func initXYZ(x: Double, y: Double, z: Double) {
        self.x = round(x)
        self.y = round(y)
        self.z = round(z)
    }
}

然后,直接在designated init方法中调用initXYZ就会导致编译错误:

class Point3D: Point2D {
    // ...
    init(x: Double = 0, y: Double = 0, z: Double = 0) {
        // Compile time error
        self.initXYZ(x: x, y: y, z: z)
    }
}

3.2 永远不要重定义继承而来的默认参数

假设,我们有一个表示形状的基类:

class Shape {
    enum Color { case red, yellow, green }
    
    func draw(color: Color = .red) {
        print("A \(color) shape.")
    }
}

它的draw方法带有一个有默认值.red的参数。
接下来,我们再定义两个Shape的派生类,分别表示方形和圆形:

class Square: Shape {
    override func draw(color: Color = .yellow) {
        print("A \(color) square.")
    }

}

class Circle: Shape {
    override func draw(color: Color = .green) {
        print("A \(color) circle.")
    }
}

在它们各自的实现里,我们给draw(:Color)方法指定了不同的默认颜色。这真的可行么?我们用下面的代码来试一下:

let s = Square()
let c = Circle()

s.draw() // A yellow square
c.draw() // a green circle 

可以看到,当s和c分别是Square和Circle对象时,修改默认参数可以很好的工作。但通常,我们会利用多态来动态选择调用的方法,像这样:

let s: Shape = Square()
let c: Shape = Circle()

s.draw() // A red square.
c.draw() // A red circle.

我们的确根据不同的对象调用了各自draw()方法的实现,但是这些方法的默认参数选择,却统统用了Shape的版本。
之所以会有这样的结果,是因为在Swift里,继承而来的方法调用是在运行时动态派发的,Swift会在运行时动态选择一个对象真正要调用的方法。但是,方法的参数,出于性能的考虑,却是静态绑定的,编译器会根据调用方法的对象的类型,绑定函数的参数。于是,就造成了之前派生类方法的实现,基类方法的默认参数这样的结果。所以,直接修改继承得来方法的默认参数,并不是个好主意。

但是,如果在派生类的实现中不定义默认参数:

class Square: Shape {
    override func draw(color: Color) {
        print("A \(color) square.")
    }
}

我们就不能使用type inference创建一个square对象时,绘制成默认的颜色了:

let s = Square()
s.draw() // Compile time error

这显然有悖于在Shape中定义draw方法的初衷。到底该怎么办呢?

一个笨方法就是我们在Square.draw中指定和Shape.draw同样的默认值参数。这样,就可以既能默认绘制Square对象,又掩盖了实际上选择的是Shape.draw方法默认参数的事实:

class Square: Shape {
    override func draw(color: Color = .red) {
        print("A \(color) square.")
    }
}

但这样真的好么?稍微多往前想一步,你就会否定这个方案。首先,如果你有上百种形状要创建,就要给每一个类中的draw方法指定相同的默认参数;其次,如果有一天基类的draw方法默认颜色改了,你又将重蹈参数选择错误的覆辙。因此,这并不是一个好方法。

为了能在重定义继承方法的同时,又继承到基类的默认参数,我们还有其它的出路么?如果你知道定义在extension中的方法,是不能被重定义的,就看到了一丝曙光。我们可以把绘画的过程抽象在一个extension方法里,供外部统一调用,然后把真正的绘制过程定义成一个可以重定义的方法。像这样:

class Shape {
    enum Color { case red, yellow, green }
    
    func doDraw(of color: Color) {
        print("A \(color) shape.")
    }
}

extension Shape {
    func draw(color: Color = .red) {
        doDraw(of: color)
    }
}

在上面的代码里,由于draw(:Color)定义在extension里,它不可以被派生类重写。但我们可以重定义没有默认参数的doDraw(:Color)方法:

class Square: Shape {
    override func doDraw(of color: Color) {
        print("A \(color) square.")
    }

}

class Circle: Shape {
    override func doDraw(of color: Color) {
        print("A \(color) circle.")
    }
}

这样,我们就变相实现了在派生类中重写方法的同时,还保留了基类API默认参数的效果。

3.3、重写继承方法的替代方案

当我们需要在派生类体系中自定义基类的某些行为时,除了重写基类方法之外,还有很多其它的方式。
为了演示这些模式的实现,我们假设一个场景。假设我们正在开发一款游戏,其中的每个角色,都有自己的攻击力(当然,你也可以假设它们还有不同的生命值、攻击范围等等,但那并不是我们要讨论的重点)。于是,你很自然的想到了,要为所有的角色抽象出来一个公共的基类,并提供一个获取攻击值的方法:

class Role {
    func power() -> Int { return 0 }
}

然后,无论是NPC也好,玩家也好甚至是Boss,它们都可以从Role派生出来,在它们各自的派生类里,我们可以定义不同的攻击力。

3.3.1 重写替代方案一:Template method

第一个要介绍的方法,来源于这样一个假设:所有可以被重写的方法都应该只被类型自身使用,而对外的API都应该是不可被重写的方法。
首先,我们让定义在Role里的方法变成只允许类内部使用:

class Role {
    fileprivate func doPower() -> Int {
        return 0
    }
}

然后,我们通过extension Role添加一个可以公共访问,但是不能改写的方法:

extension Role {
    public func power() -> Int {
        // pre settings here
        let value = doPower()
        // post settings here
        return value
    }
}

这样,所有Role的派生类只需要做两件事情:
(1)从Role派生;
(2)重定义doPower()方法;

就可以定义它们自己的攻击力了。在这种模式里,extension中的power()方法,为读取所有类型角色的攻击力提供了一份模板实现,因此这种模式被称作template method。

这种方式的一个问题在于,对于熟悉了面向对象编程的我们来说,要接受在派生类中改写的居然是不会被公众调用的私人方法这个观点,着实有些挑战,它似乎有悖于我们对面向对象的理解:

class Player: Role {
    fileprivate func doPower() -> Int {
        return 100
    }
}

但实际上,这种只重写私有方法的做法并没有那么诡异。我们在派生类中重写一个方法的目的只是为了表达“某些事情”如何完成(例如我们例子中的获取攻击力)。而调用一个被继承的方法,则是为了表达“某些事情”在何时完成。所以,是否在派生类中改写方法,与一个方法的访问权限,并不是有很大的关联。

3.3.2 重写替代方案二:基于函数的Strategy模式

除了让一个不可被重写的方法提供模板外,还有一种更“激进”的做法。为什么我们一定要让获取攻击力这个行为和角色类型相关呢?这个关系难道不是我们可以动态指定的么?于是,就有了下面这种用一个函数属性保存计算攻击力的方法。

首先,定义一个接受Role参数,并返回Int攻击力的方法:

func defaultPower(role: Role) -> Int {
    return 0
}

其次,我们把Role改成这样:

class Role {
    typealias PowerFn = (Role) -> Int

    var powerFn: PowerFn

    init(powerFn: @escaping PowerFn = defaultPower) {
        self.powerFn = powerFn
    }
}

此时,Role就有了一个函数属性,它用于计算某类角色的攻击力。最后,我们把Role的模板方法改成这样:

extension Role {
    func power() -> Int {
        return powerFn(self)
    }
}

相比于之前的“模板”方案,基于函数属性的实现多了更多的灵活性,例如:
(1)同一类型的对象现在也可以有不同的计算攻击力的方法:

let p1 = Player(powerFn: { _ in 100 })
let p2 = Player(powerFn: { _ in 200 })

(2)我们还可以在运行时,动态修改某个对象计算攻击力的方法:

p1.powerFn = { _ in 50 }

(3)甚至,我们还可以为攻击力的计算添加任意的附加条件。例如,我们添加一个表示游戏难度的enum,它带有一个根据游戏难度返回攻击力的方法:

enum Level {
    case simple, normal, hard

    func rolePower(role: Role) -> Int {
        switch self {
            case .simple:
                return 300
            case .normal:
                return 200
            case .hard:
                return 100
        }
    }
}

我们就可以这样来设置一个玩家的攻击力:

let level = Level.simple
p1.powerFn = Level.rolePower(level)

3.3.3 重写替代方案三:基于class的strategy模式

class Power {
    func calc(role: Role) -> Int {
        return 100
    }
}

然后,让Role保存一个Power类型的属性:

class Role {
    var powerFn: Power

    init(powerFn: Power) {
        self.powerFn = powerFn
    }
}

最后,让Role.power的计算通过Power.calc完成:

extension Role {
    func power() -> Int {
        return powerFn.calc(role: self)
    }
}

4、使用访问控制管理代码

当代码日益复杂之后,你就会自然而然的去思考一些和访问控制相关的话题。

4.1 从module说起

一个import进来的程序库是一个module,一个单独执行的应用程序也是一个module。总之,一个module就是一个可以被分发的Swift代码单位。
创建module最简单的方式,就是通过Swift自带的Package Manager。首先,我们新建一个Compiler目录,进入目录后,执行下面的命令:

swift package init --type=library

dfdsf
这时,在Sources目录中就存在了一个module,叫Compiler。但我们希望把所有Compiler功能性的部分放在一个类似程序库的module里,把所有应用类的代码放在另外一个可执行module里。于是,我们需要把Sources目录变成这样:
请添加图片描述
此时,在Source目录里,每一个子目录就表示了一个module。稍后,就会看到,为了在main.swift中使用Compiler.swift的代码,我们就需要使用import Compiler。

别着急,准备工作还没结束,我们还需要告诉Swift,Sources目录中modules之间的关系。打开Package.swift,添加下面的配置:

let package = Package(
    name: "Compiler",
    targets: [
        Target(name: "Application", dependencies: ["Compiler"])
    ]
)

保存退出后,执行下swift build,如果没有错误,就表示一切都准备就绪了。此时,我们包含了两个module,一个叫做Compiler,用来编写编译器的库程序,另一部分,用来编写编译器的应用程序。

4.2 使用public定义开放且不需要修改的类和方法

public的作用,就是让一个类型或方法,对所有的module都可见。但是,可见的含义是只开放给你使用,对于类型来说,不允许你派生;对于方法来说,也不允许你重写。

首先,在Compiler.swift里,新建一个class Compiler:

class Compiler {}

我们还不用添加任何具体的实现,先用它示意即可。然后,打开main.swift,在这里,要想使用Compiler module中的代码,我们就要在文件开始添加:import Compiler。但这时,我们却还不能直接创建Compiler对象:

import Compiler

let lang = Compiler() // Compile time error

这是因为在Application module里,默认是看不到class Compiler的。为了解决这个问题,我们要使用public来修饰它:

public class Compiler() {

}

但这样做还是不行,编译器会提示我们,Compiler的init方法不可访问。没错,你要记得一点,在Swift里,对类型的修饰并不会波及到它的属性和方法。所以,我们还要至少添加一个public init方法:

public class Compiler {
    public init() { 
        print("Compiler initiated") 
    }
}

这样,代码就可以正常通过编译了。所以,简单来说,public的作用,就是让一个类型或方法,对所有的module都可见。但是,可见的含义是只开放给你使用,对于类型来说,不允许你派生;对于方法来说,也不允许你重写。所以,在main.swift里,下面的代码是无法通过编译的:

class MyCompiler: Compiler {
    // Cannot compile out of Compiler module
}

所以,public更适合共享具有值类型语义的对象。而要派生一个自定义类型,我们需要使用更开放的权限:open。

4.3 使用open定义开放且需要扩展的接口

把Compiler的定义改成这样:

open class Compiler {
    public init() { 
        print("Compiler initiated") 
    }
}

我们之前定义的MyCompiler就可以通过编译了。接下来,我们给Compiler添加一个属性,表示所有要编译的源文件,并对init做对应的修改:

open class Compiler {
    var sourceFiles: [String]
    
    public init(_ sourceFiles: [String]) {
        self.sourceFiles = sourceFiles
        print("Compiler initiated")
    }
}

然后,在main.swift里,对应的修改MyCompiler:

class MyCompiler: Compiler {
    public override init(_ sourceFiles: [String]) {
        super.init(sourceFiles)
        print("MyCompiler initiated")
    }
}

在这里,有一个细节。public修饰的方法是不能在派生类中重写的。但对init方法来说,这是一个例外。并且,我们只能用public修饰init方法。

然后,我们给Compiler添加下面的方法:

open class Compiler {
    // ...

    func build() {
        sourceFiles.forEach {
            // Phase 1
            compile(filePath: $0)
            // Phase 2
            assemble(filePath: $0)
        }

        // Phase 3
        link()
    }

    func compile(filePath: String) { }

    func assemble(filePath: String) { }

    func link() { }
}

其中,build方法用来统揽编译的全过程,它调用了三个方法compile,assemble和link用来表示编译的三个阶段。我们该如何设置它们的访问权限呢?

对于build来说,我们只希望共享它的执行逻辑,并不希望用户改变编译->汇编->链接这个流程,也就是说,它是一个不应该被重写的方法,因此,我们应该使用public。

而对于compile,assemble和link来说,我们使用不同的前端工具、针对不同的处理器以及操作系统,这三个环节的代码都有可能不同,因此它们应该可以被Compiler的派生类改写,所以,它们都应该是open方法。最终,它们的定义就应该是这样的:

open class Compiler {
    // ...

    public func build() {
        // ...
    }

    open func compile(filePath: String) { }

    open func assemble(filePath: String) { }

    open link() { }
}

4.4 默认的internal

在之前Compiler的定义里,我们没有为sourceFiles指定访问权限,但这并不等于它没有访问权限。Swift默认的访问权限是internal,意思是只针对module内的所有代码开放。绝大部分时候,我们都在用这种类型的权限。

4.5 使用fileprivate在单个文件内共享代码

fileprivate是比internal更严格的限制,让我们只可以在单个文件内访问被修饰的成员。例如,在Compiler.swift中,把sourceFiles改成:

open class Compiler {
    fileprivate var sourceFiles: [String]
    // ...
}

这样,即便是在Compiler module里,我们也只能在Compiler.swift这个文件内使用sourceFiles了。

4.6 使用private定义在一个词法单位内使用的代码

最后一类访问权限,叫做private。它是最严格的访问权限,其约束的内容,只能在其定义的语法范围内访问。对于一个自定义类型来说,private约束的内容,完全属于这个类型的内部实现细节。你既可以把这种细节理解为是外部不可见的,也可以理解为是一个自定义类型内部“与生俱来,不可改变”的属性。

例如,我们要在编译的每个阶段记录日志,可以这样。

首先,为Compiler添加一个内部类Log,它专供Compiler内部实现使用,不对外提供服务:

open class Compiler {
    private class Log {
        static func info() {}
    }

    // ...
}

这里,由于Log的访问权限是private,它的class methods必然只能在Log定义的词法范围内使用,也就是在Compiler类定义的内部,我们也就无需再为这些方法设置权限了。

然后,我们就可以在编译的每一个阶段完成后,添加日志:

open class Compiler {
    // ...
    public func build() {
        sourceFiles.forEach {
            // Phase 1
            compile(filePath: $0)
            Log.info("\($0) compile finished")
            
            // Phase 2
            assemble(filePath: $0)
            Log.info("\($0) assemble finished")
        }

        // Phase 3
        link()
        Log.info("Link finished")
    }
}

这样的实现方式表明,“在特定的阶段完成后记录日志”这完全是build内部既定的行为,Compiler的使用者只能使用或者忽略它产生的日志,而无法禁用或修改它。

就像我们一开始说的一样,Log是个private类型表示它对外的不可见的;而这样一个类型提供的功能,表示了这是Compiler“与生俱来,不可改变”的属性。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值