面向对象是一种古老且生机勃勃的编程范式,甚至可以追述到上世纪六十年代。在之前的几篇教程中,我们暂且搁置了类与对象的概念,着重讨论了常量、变量、函数以及闭包,这是因为Swift给了我们一个机会,能够在Playground的环境中暂时把class放在一边,但最终,我们还要回到面向对象,回到class的怀抱,它是编写软件的根基,每一个class的声明,就是对蓝图细致的描绘,每一个对象实例的诞生,就是软件的呼吸的律动。

一个简单的class声明如下:

class Shape {
    var numberOfSides = 0
    func simpleDescription() -> String {
        return "A shape with \(numberOfSides) sides."
    }
}

好像并没有什么特别的,一个class关键字后面跟上你想要的名字,再用花括号紧随其后包裹一个区域。在这个区域内,你就可以填上前几节课教的常量、变量与方法了。

上面的代码,仅仅是声明了一个class,如果像使用它,还需要造个对象出来,也就是创建一个该class实例,一个简单的例子如下:

var shape = Shape()
shape.numberOfSides = 7
var shapeDescription = shape.simpleDescription()
print(shapeDescription)

在第一行,我们创建了一个叫shape的实例,它拥有的变量及函数就是我们在Shape这个class声明的。一个class可以有千千万万个实例,但是每一个实例都享受它自己的特殊性,在这个例子里,就是numberOfSides变量了。为了让shape这个独一无二的对象(实例)拥有它自己的特殊的numberOfSides内容(数字7),就有了上面代码的第二行。

如果我们回过头来审视第一行代码——Shape(),会发现这种写法与函数调用基本没啥区别,只是目前调用这个函数时没有提供任何参数罢了。那么有没有一种可能,在创建对象实例的时候,我们能传点参数进去。用上面的代码举例子,就是我们把原本两行的代码:

var shape = Shape()
shape.numberOfSides = 7

精简成:

var shape = Shape(numberOfSides: 7)

这其实是一种很常见的需求,只需要我们在Shape类里面多提供一个方法,最终的Shape代码如下:

class Shape {
    var numberOfSides = 0
    
    init(numberOfSides : Int) {
       self.numberOfSides = numberOfSides
    }
    
    func simpleDescription() -> String {
        return "A shape with \(numberOfSides) sides."
    }
}

其中init函数就是我们刚刚追加的,这种函数会在对象被构造出来的时候执行,所以这种特殊的函数就叫构造函数。至于函数代码段中的那个self,见文知意,就是说的是实例的自身。self.numberOfSides = numberOfSides的意思就是把函数接收到的参数numberOfSides内容赋值给self.numberOfSides,也就是本实例的变量numberOfSides。还应注意的是,与上节课我们介绍的函数使用类似,如果我们在声明init函数参数的时候前面加个_,就像下面这样:

init(_ numberOfSides : Int) {
   self.numberOfSides = numberOfSides
}

那么我们就可以在创建Shape实例时,省略参数的名字,像这样:

var shape = Shape(7)

话说回来,如果你只想在对象被实例化的时候做点什么小动作,但是并不一定需要接收什么参数,其实你可以使用无参数的init函数,比如下面这段代码,我们就只是在shape生成时打印了一句话而已。

class Shape {
    var numberOfSides = 0
    
    init() {
       print("i am here")
    }
    
    func simpleDescription() -> String {
        return "A shape with \(numberOfSides) sides."
    }
}

var shape = Shape()

既然init是用来解决对象创建时做点什么的问题,那么对象销毁时,是不是也会有做点什么的需求呢?如果有这样的需求,代码要写在什么地方呢?答案就是deinit。与init一样,他们都是可选的,只在你需要的时候,可以召唤他们。但是与init不同的是,deinit不会接受任何参数,因而它不是一个正儿八经的函数。如果需要在Shape对象销毁时打印一句话,我们可以这样写:

class Shape {
    var numberOfSides = 0
    
    init() {
       print("i am here")
    }

    deinit {
        print("deinit")
    }
    
    func simpleDescription() -> String {
        return "A shape with \(numberOfSides) sides."
    }
}

想测试上面的deinit,我们就必须想办法销毁一个对象。最简单的办法就是把一个原本存储了对象的变量设置成nil,就像下面这样:

var shape:Shape? = Shape()
shape = nil

如果你对第一行代码中?感到疑惑,建议再回顾下本系列文章的第一篇。

面向对象的一个明显优势就是可以通过类的继承来组织类与类之间的关系,从而大幅减少代码重复率提升代码的可维护性。请看下面的例子:

class NamedShape {
    var numberOfSides: Int = 0
    var name: String

    init(name: String) {
       self.name = name
    }
    
    func simpleDescription() -> String {
       return "A shape with \(numberOfSides) sides."
    }
}

我们这里准备了一个叫NamedShape的类,它用来存储各种形状,通过init函数,我们要求创建形状的时候,需要给具体的形状起个名字。同时我们的形状也拥有numberOfSides变量,用来记录具体形状拥有的边的数量。

有了NamedShape,当我们考虑创建正方形(Square)这种形状的时候,就会发现正方形本质上也是一种形状,它跟NamedShape有很多共通之处,只是numberOfSides是已被确定的值,应该填入4。来看下面的代码:

class Square: NamedShape {
    var sideLength: Double

    init(sideLength: Double, name: String) {
        self.sideLength = sideLength
        super.init(name: name)
        numberOfSides = 4
    }

    func area() -> Double {
        return sideLength * sideLength
    }

    override func simpleDescription() -> String {
        return "A square with sides of length \(sideLength)."
    }
}
let test = Square(sideLength: 5.2, name: "my test square")
test.area()
test.simpleDescription()

在代码第一行,声明Square类的时候,我们通过:,明确了SquareNamedShape继承关系。然后Square就拥有了NamedShape已拥有的一切。比如numberOfSides = 4中的numberOfSides就是原本属于NamedShape的变量。而super.init(name: name)其实就是在子类中调用了其父类的init构造函数。因为init这个函数,子类也有,父类也有,所以当子类调用父类的函数时,需要提供super.,用来明确要调用的函数是属于父类的。不光构造方法,如果是要调用父类的其他方法,也是同样的道理,都需要用super.明确。甚至那句numberOfSides = 4也可以写成super.numberOfSides = 4,道理也是一样的。

还应注意的是simpleDescription函数,这里我们在func之前追加了关键字override,是因为NamedShapesimpleDescription函数功能不满足需求,我们想用Square新定义的。但是因为函数名在父、子类中是相同的,所以我们需要在子类中通过override明确覆盖掉父类的同名方法。

在前面的例子中我们演示了一些属于类的变量的使用,比如:numberOfSides,这样的变量跟第一节课讲的没啥区别。当然我们可以在类内定义常量,这些变量和常量,都可以叫做是类的属性,确切的说应该叫存储属性。但是有一种属性非常特殊,我们叫它计算属性,我们来看下面的例子:

class EquilateralTriangle: NamedShape {
    var sideLength: Double = 0.0

    init(sideLength: Double, name: String) {
        self.sideLength = sideLength
        super.init(name: name)
        numberOfSides = 3
    }

    var perimeter: Double {
        get {
             return 3.0 * sideLength
        }
        set {
            sideLength = newValue / 3.0
        }
    }

    override func simpleDescription() -> String {
        return "An equilateral triangle with sides of length \(sideLength)."
    }
}

其中perimeter就是一种计算属性,这种计算属性对于使用方来说,就像是普通的属性一样,比如下面这种调用方式:

var triangle = EquilateralTriangle(sideLength: 3.1, name: "a triangle")
print(triangle.perimeter)
triangle.perimeter = 9
print(triangle.sideLength)

我们通过构造函数,创造了一个名叫a triangle的正三角形,它的边长为3.1,所以其实它的周长是可以被推算出来的,我们就可以省去设置周长的代码。所以在上面代码的第二行,我们可以得到9.3的结果。

但是当我们执行到第三行的时候,如果我们非要把正三角形的周长改成9.9,那么显然相应地正三角形的边长也会发生改变。因而我们在第四行代码可以得到这样的结果:3.0

所以计算属性的使用,对外围的使用者来说,就像在访问普通的变量一样轻松,没有任何区别。但其实,对类内部来说,计算属性的访问是可以隐含着一系列运算的。具体到上面的例子,当我们相查询perimeter时,其实命中的是get的代码段,而当我们试图给perimeter赋值时,命中的就是set代码段,并且在set代码段内,我们拥有一个天然的局部值newValue,它的类型,就是对应计算属性的类型,它的值,就是我们相给它塞的值,在上面的代码中,就是9

顺便一说,上面代码中的这段:

get {
     return 3.0 * sideLength
}

return关键字也是可以省略的。

同时,如果你想构建一个只读计算属性也是可以的,比如这样:

var perimeter: Double {
    get {
         3.0 * sideLength
    }
}

那么显然perimeter就只能用来获取,而不可以被赋值了。在构造这种只读属性的时候,代码还可以进一步简化成下面的样子:

var perimeter: Double {
    return 3.0 * sideLength
}

而且你应该也能猜到,这里的return是可以省略的。

好了,关于Swift中类和对象的基本用法就先介绍到这里,下期我们会介绍枚举和结构体类型。

阅读相关文章:

Swift 初体验(1)
Swift 初体验(2)- 久仰大名 switch
Swift 初体验(3)- 函数和闭包