理解Swift的Enum,Struct和Class
前言
本文翻译自Getting to Know Enums, Structs and Classes in Swift
翻译的不对的地方还请多多包涵指正,谢谢~
理解Swift的Enum,Struct和Class
回到只有Objective-C时代,封装特性仅限于类。但在Swift的世界现代,iOS和Mac编程中enums, structs, classes
的任何一个都能有封装性。
结合协议(protocol
),这些类型使做一些不可思议的事儿成为可能。虽然他们有很多共同的特性,但这些类型也有很大的不同点。
本篇教程目的:
- 让您了解使用
enums, structs, classes
的一些经验 - 了解这些类型使用时机一点经验
- 让您了解每个类型的工作原理
前提,教程假定您有至少一点Swift和面向对象的编程经验。
关于类型
Swift三大卖点是:安全,迅速及简洁。
安全是指Swift中编写混乱的,脏内存的,bug很难寻找的代码是很难的。Swift让你的工作更加安全因为它能在编译时期当有bug时让bug变得很明显,而不是在运行时崩溃。
而且,因为Swift能让你清晰表达你的意图,优化器能够在底层让你的代码快如闪电。
Swift核心非常简单且高度规范化,这得益于它建立在很小数的概念上。尽管规则相对简单,却能干令人惊讶的事情。
能让这一切发生的关键点得益于Swift的类型系统:
虽然只有六种,但Swift类型功能很强大。没错,不像其他语言有数十种内置类型,Swift仅有六种。
这些类型包括4种有名类型: protocol, enum, struct, class
。两种复合类型:tuple, function
。
其他的你会想到的基本类型,比如:Bool, Int, UInt, Float, Double, Character, String, Array, Set, Dictionary, Optional
等等。但是,这些类型实际上都是建立在有名类型上,并作为Swift标准库一部分。
本教程关注于有名类型上:enum, struct, class
。
SVG图形例子
作为开发例子,我们将建立一个安全的,运行快速的,简单的SVG图形渲染框架。
SVG是一种为2D图形的基于XML矢量图形格式。早在1999 W3C 组织就开放了对SVG的详细说明.
开始吧
在XCode中,通过File/New/Playground...创建一个工作区。并且将其命名为Shapes
,且将平台设置为OS X。点击下一个选择存放位置,接着创建并保存。清空文件内所有内容并输入以下内容:
import Foundation
你的目标是能够像这样做渲染:
<!DOCTYPE html>
<html><body>
<svg width='250' height='250'><rect x='110.0' y='10.0' width='100.0' height='130.0' stroke='teal' fill='aqua' stroke-width='5' /><circle cx='80.0' cy='160.0' r='60.0' stroke='red' fill='yellow' stroke-width='5' />
</svg>
</body></html>
结合WebKit View做显示,它最终看起来是这样:
你将需要颜色的解释器。SVG使用CSS3的颜色类型,它可以通过 颜色名字,RGB 或 HSL来指定。详细说明点这里.
为了在SVG中使用颜色,你需要指定它作为你绘制部分的一个属性。例如,fill = 'gray'
。Swift一种简单的方式表达式直接创建一个String,let fill = 'gray'
.
虽然使用String是非常简单的方式而且还实现了功能,但它有几点不好。
- 很可能出错。任何不能表示颜色的字符串在编译时期正常,但是运行时确显示错误。例如,拼写错误的“grey”就不代表颜色。
- 没有自动补全,不能帮你自动找到合法的颜色名字。
- 当字符串名字的颜色作为参数传递,可能并不能从名字上看出是一种颜色。
Enum 来救场
使用自定义类型来解决这些问题。如果你从Cocoa Touch过来的,你可能会想到去实现类似于UIColor
的封装类。虽然使用类的方式可以解决这个问题,Swift给了你更多选择定义你的数据模型。
写代码前,来想象一下使用enum来实现颜色的定义。
你可能会这样实现:
enum ColorName {
case black
case silver
case gray
case white
case maroon
case red
// ... and so on ...
}
上述代码看起来像是C风格的enum。但与C语言enum不同的是,Swift能让你为每个case定义一种类型。
枚举可以显式指定后背存储的数据类型,它是通过RawRepresentable
协议实现的。Enum
自动实现了RawRepresentable
。
因此,你可以指定ColorName
类型为String,并未每个case设置一个值,例如:
enum ColorName: String {
case black = "black"
case silver = "silver"
case gray = "gray"
case white = "white"
case maroon = "maroon"
case red = "red"
// ... and so on ...
}
但Swift为类型为String代码的Enum
做了一些特殊的事情。若你没有指定每个case的值,编译器会自动让每个case原始值等于它case的名字。这意味着上述代码你只需要这么写:
enum ColorName: String {
case black
case silver
case gray
case white
case maroon
case red
// ... and so on ...
}
你可以通过只写一次 case
且每个case的名字用逗号隔开来减少你的代码:
enum ColorName: String {
case black, silver, gray, white, maroon, red, purple, fuchsia, green, lime, olive, yellow, navy, blue, teal, aqua
}
现在你已有一个自定义的类型且所有的优点已经出来了,例如:
let fill = ColorName.grey // 错误: 拼写错误的颜色名字编译不过. 很好!
let fill = ColorName.gray // 正确的颜色能够自动补全且编译. 耶!
关联值(Associated Values)
ColorName
对于有名字的颜色来讲很不错,但你可能记得CSS颜色有很多表达方式:名字,RGB,HSL等等。你怎么为他们设计结构呢?
Swift中Enum
来定义有多种表达方式的数据结构非常棒,诸如:CSS颜色。且每个枚举都有自己属于自己的数据结构。这些数据被称为关联值(Associated Values)。
使用Enum
定义CSSColor
,把以下代码加入到你的工作区:
enum CSSColor {
case named(ColorName)
case rgb(UInt8, UInt8, UInt8)
}
通过这个定义,你赋予CSSColor
模型有两种状态:
- 它能使用字符串命名,在这里关联的数据是
ColorName
类型值 - 它能够使用rgb复制,此时关联的数据结构是三个
UInt8
(0-255)类型的数据代表红,绿,蓝
注意到我们为了简洁介绍,例子中没有rgba,hsl及hsla类型。
Enum中的协议和方法
你希望能输出CSSColor
多种实例。
在Swift中,Enum
和其他有名的类型一样,能使用协议。特别的,你的类型采用CustomStringConvertible
协议后能神奇的和打印合作在一块。
和Swift标准库相互操作的关键点在于采取标准库协议。
添加以下CSSColor
的扩展到你的工作区:
extension CSSColor: CustomStringConvertible {
var description: String {
switch self {
case .named(let colorName):
return colorName.rawValue
case .rgb(let red, let green, let blue):
return String(format: "#%02X%02X%02X", red,green,blue)
}
}
}
这里让CSSColor
遵循了CustomStringConvertible
协议。这样就告诉Swift我们的类型CSSColor
能转成字符串。我们是通过实现description
的属性的获取方法来告诉它的。
在这里的实现中,self
随着实际的有名字的或者RGB类型而切换到对应的类型。每个case中会根据不同的类型转换成对应的字符串。以名字命名的颜色直接返回名字,而RGB类型的返回红绿蓝格式的值。
添加以下代码到工作区:
let color1 = CSSColor.named(.red)
let color2 = CSSColor.rgb(0xAA, 0xAA, 0xAA)
print("color1 = \(color1), color2 = \(color2)") // prints color1 = red, color2 = #AAAAAA
不像你仅仅使用String来表达颜色那样,上述所有代码都是类型检查过且在编译时期检验正确的。
注:虽然你可以回到之前CSSColor定义的地方修改它,但你没必要。使用扩展展开颜色类型并且采用一个协议就可以啦。
扩展的方式是非常棒的,因为它可以让你实现既定的协议显式地指出你要做的事情。上述CustomStringConvertible
要求你实现description
的属性的获取方法。
Enum的初始化
就像Swift中类和结构体一样,你可以为Enum
添加自定义的初始化方法。例如,你可以为颜色灰度值提供一个自定义的初始化方法。
添加以下代码到工作区中:
extension CSSColor {
init(gray: UInt8) {
self = .rgb(gray, gray, gray)
}
}
Enum命名空间
有名的类型能扮演命名空间的角色来使事情良好地组织起来并减少复杂度。你创建了ColorName
和CSSColor
,但是ColorName
只在CSSColor
中使用了。
如果能让ColorName
隐藏在CSSColor
中是不是很棒?
OK,你可以的~ 从工作区移除ColorName
然后添加以下代码:
extension CSSColor {
enum ColorName: String {
case black, silver, gray, white, maroon, red, purple, fuchsia, green, lime, olive, yellow, navy, blue, teal, aqua
}
}
将ColorName
代码移入到CSSColor
中。现在ColorName
内聚到CSSColor
中,作为内部类型存在。
Swift其中一个非常棒的特性是声明类型的顺序通常是不用关心的。编译器会多次扫描文件并计算出来类型的声明,而不需要像
C/C++/Objective-C
一样前置声明。
但是,如果在工作区你看到关于ColorName
是一个没有声明类型的错误,将上述扩展移到CSSColor
定义的下面可以消除工作区的错误。
有时候工作区对于定义很敏感,虽然它并不关心。
枚举可以用于作为纯粹的命名空间,这样使用者不会错误的使用。例如:之后你变会用到数字常量phi用于一些计算。
添加以下代码到工作区:
enum Math {
static let phi = 1.6180339887498948482 // 黄金比率
}
因为Math
枚举没有case,且在扩展中添加新的case是不合法的,它永远不可能实例化。作为变量或者参数,你也永远不能意外错用Math
。
通过声明phi为一个静态常量,你不需要实例化。无论何时你需要黄金比率的值,使用Math.phi
就可以不用去记这些数字~
Enum小结
相较于C和Objective-C等语言,Swift中的枚举更加强大。如你所见,Enum
可以扩展,可以自定义初始化方法,提供命名空间,封装相关操作。
到此你已经用Enum
创建了CSS颜色的结构。它工作得很好因为CSS颜色是易于理解,有固定的W3C规范。
对于从一个列表中抽出一些品类的类似功能,诸如:一周中的每天,硬币两面,状态机状态,枚举能工作的非常好。一点也不奇怪,Swift中optionals
就是使用拥有.none .some
两种状态的枚举来实现的。
从另一方面来讲,如果你希望CSSColor
做到用户可扩展到W3C中没有定义过的其他颜色命名空间的话,枚举不是一个首选的抽象方式。
那么,顺带着,Swift下一个有名结构类型,结构体就要出场啦~
使用结构体(Using Structs)
你希望你的用户能在SVG定义他们自己的图形,使用枚举对于定义图形来说不是一个好的选择。
新的枚举case不能添加在枚举扩展中。那么就剩下 class struct
了。
Swift标准库建议当你创建一个新的数据类型时,最好用协议先设计他们的接口。希望你的图形能够可绘制,那么添加以下代码到工作区吧:
protocol Drawable {
func draw(context: DrawingContext)
}
协议定义了Drawable
的含义。它有一个绘制的方法,画在一个叫DrawingContext
的结构上。
谈到DrawingContext
,它是另一个协议。添加以下代码吧:
protocol DrawingContext {
func draw(circle: Circle)
// more primitives will go here ...
}
协议DrawingContext
知道如何绘制纯粹的几何图形:圆,长方形等其他原始图形。注意这里:实际绘制的技术并没有指定,但是你可以实现它,比如在SVG, HTML5 Canvas, Core Graphics, OpenGL, Metal, 等等中。
准备定义一个圆形并实现Drawable
协议。添加以下代码到工作区:
struct Circle : Drawable {
var strokeWidth = 5
var strokeColor = CSSColor.named(.red)
var fillColor = CSSColor.named(.yellow)
var center = (x: 80.0, y: 160.0)
var radius = 60.0
// Adopting the Drawable protocol.
func draw(context: DrawingContext) {
context.draw(circle: self)
}
}
在这个结构体中,你集合了存储的属性。下面是这些属性的解释:
- strokeWidth:线条的宽度
- strokeColor:线条的颜色
- fillColor:填充圆的颜色
- center:圆的中心点
- radius:圆的半径
结构体和类非常像,只有几个关键点不同。也许他们最大的不同点在于结构体是值类型而类时引用类型。
值 vs 引用
值类型运行时是作为分开的并且不同的实体而存在的。
最为典型的值类型是整形,因为在绝大多数编程语言中都是作为值类型。如果想知道值类型怎么工作的,“Int类型会怎么做呢”,例如:
对于Int:
var a = 10
var b = a
a = 30 // b 仍然是 10.
a == b // false,不相等
对于Circle(使用结构体定义的):
var a = Circle()
a.radius = 60.0
var b = a
a.radius = 1000.0 // b.radius 仍然是 60.0
对于Circle(使用类定义):
var a = Circle() // 基于类定义 circle
a.radius = 60.0
var b = a
a.radius = 1000.0 // b.radius 也是 1000.0
当使用值类型创建新的对象时,此时是拷贝;当使用引用类型时,新的变量指向同一个对象。这就是类和结构体行为上最大的不同点。
矩形数据
添加以下代码至工作区通过创建矩形类型来制作绘制库:
struct Rectangle : Drawable {
var strokeWidth = 5
var strokeColor = CSSColor.named(.teal)
var fillColor = CSSColor.named(.aqua)
var origin = (x: 110.0, y: 10.0)
var size = (width: 100.0, height: 130.0)
func draw(context: DrawingContext) {
context.draw(self)
}
}
你也需要更新DrawingContext
协议这样它能够知道怎样画矩形。在工作区更新DrawingContext
协议如下:
protocol DrawingContext {
func draw(_ circle: Circle)
func draw(_ rectangle: Rectangle)
// more primitives would go here ...
}
Circle和Rectangle采取drawalbe协议。他们遵循相同的DrawingContext
协议但是实际绘制工作的事情是不一样的。
现在是时候创建一个具体的结构来做SVG类型的绘制工作了。添加以下代码:
final class SVGContext : DrawingContext {
private var commands: [String] = []
var width = 250
var height = 250
// 1
func draw(circle: Circle) {
commands.append("<circle cx='\(circle.center.x)' cy='\(circle.center.y)\' r='\(circle.radius)' stroke='\(circle.strokeColor)' fill='\(circle.fillColor)' stroke-width='\(circle.strokeWidth)' />")
}
// 2
func draw(rectangle: Rectangle) {
commands.append("<rect x='\(rectangle.origin.x)' y='\(rectangle.origin.y)' width='\(rectangle.size.width)' height='\(rectangle.size.height)' stroke='\(rectangle.strokeColor)' fill='\(rectangle.fillColor)' stroke-width='\(rectangle.strokeWidth)' />")
}
var svgString: String {
var output = "<svg width='\(width)' height='\(height)'>"
for command in commands {
output += command
}
output += "</svg>"
return output
}
var htmlString: String {
return "<!DOCTYPE html><html><body>" + svgString + "</body></html>"
}
}
SVGContext
是一个封装了私有用来存放命令的数组的类。在段落1和2中,遵循了DrawingContext
协议,且draw方法仅仅是在数组末尾添加了一个能渲染图形的XML字符串。
最终,你需要一个文档类型来存放许多可绘制的对象,因此在工作区添加如下代码:
struct SVGDocument {
var drawables: [Drawable] = []
var htmlString: String {
let context = SVGContext()
for drawable in drawables {
drawable.draw(context: context)
}
return context.htmlString
}
mutating func append(_ drawable: Drawable) {
drawables.append(drawable)
}
}
代码中,htmlString
在SVGDocument
是一个计算出来的属性值。SVGDocument
创建一个SVGCotext
对象并用其返回htmlString。
展示一些SVG图片
我们最后画出的SVG怎样了?添加如下代码到你的工作区中:
var document = SVGDocument()
let rectangle = Rectangle()
document.append(rectangle)
let circle = Circle()
document.append(circle)
let htmlString = document.htmlString
print(htmlString)
这段代码创建了一个默认的圆和矩形,并将他们放入document对象内,之后便打印出XML。
让我们将SVG可视化吧。添加如下代码到工作区的底部:
import WebKit
import PlaygroundSupport
let view = WKWebView(frame: CGRect(x: 0, y: 0, width: 250, height: 250))
view.loadHTMLString(htmlString, baseURL: nil)
PlaygroundPage.current.liveView = view
这里使用web界面来显示SVG。按下 Command+Option+Return 键就会在帮助编辑器内(assistant editor)看到web界面。
使用类
到这儿,你已经使用了结构体组合和协议实现了绘制的结构。
是时候试试类啦~ 它们能让你使用基类及继承类。解决图形问题的传统面向对象方式就是创建一个拥有draw()
方法的shape基类。
即使你现在不会使用它,理解这种方式如何工作的也是有帮助的。它的结构看起来是这样的:
在代码中,看起来也会像以下block一样 --- 仅仅用于说明,不要放入你的工作区内:
class Shape {
var strokeWidth = 1
var strokeColor = CSSColor.named(.black)
var fillColor = CSSColor.named(.black)
var origin = (x: 0.0, y: 0.0)
func draw(with context: DrawingContext) { fatalError("not implemented") }
}
class Circle: Shape {
override init() {
super.init()
strokeWidth = 5
strokeColor = CSSColor.named(.red)
fillColor = CSSColor.named(.yellow)
origin = (x: 80.0, y: 80.0)
}
var radius = 60.0
override func draw(with context: DrawingContext) {
context.draw(self)
}
}
class Rectangle: Shape {
override init() {
super.init()
strokeWidth = 5
strokeColor = CSSColor.named(.teal)
fillColor = CSSColor.named(.aqua)
origin = (x: 110.0, y: 10.0)
}
var size = (width: 100.0, height: 130.0)
override func draw(with context: DrawingContext) {
context.draw(self)
}
}
为了面向对象编程更加安全,Swift引入了override
关键字。它会提醒编程人员知道他在覆盖父类方法。
它能防止意外地隐藏现有的方法或者不合理的覆盖你认为不需要覆盖的。在使用新版的库或者理解已经发生的变化时,它可能是个救命者(可能提供很大帮助)。
不管怎样,这种实现是有一些缺陷的。
你会注意到第一个问题是,基类实现了draw方法,Shape类希望避免误用了draw方法。因此它在里面调用了fatalError()
警告继承类需要覆盖此方法。
不幸的是,这个检查过程发生在运行时而不是编译时。
第二个问题,子类Circle和Rectangle不得不处理子类数据的初始化。虽然这是一个相对简单的场景,为保证正确性,类的初始化变成一种被强引入的处理。
第三,对于未来子类的扩展来说不太好。
例如,假使你希望添加一个可绘制的 Line 类型。为了配合你现有的系统,它将不得不继承于 Shape 类,这样多多少少有点不恰当。
此外,Line类型初始化时给 fillColor
属性赋值,这对于一个线条类型来说是没有必要的。
基于这些点,你可能会修改他们类的层级让它们更好。但是,实际上呢,可能在不改变已存在的对象的基础上,修改你的基类是不可能的。且通常情况下,第一次修改往往是改不好的。
最后,之前我们讨论的,类是有引用传递的。虽然自动引用计数在大多数情况下是能很好地执行,但你仍然需要注意引用循环,不然就会产生内存泄漏。
如果你添加相同shape对象到一个shape数组内,当你修改数组中某一个shape的颜色时,你会惊讶地发现其他的对象的颜色可能也发生了变化。
为什么还要用类呢
陈列了一堆缺点,你可能会想为什么还要使用类呢?
对于新手来讲,他们允许你使用成熟的并经过测试的框架,比如:Cocoa 和 Cocoa Touch。
并且,类确实有更加重要的使用方式。例如,一个内存占用巨大,拷贝昂贵的对象适合用类来做封装。类适合为一个同一性的对象建模。你可能会遇到一种情况:多个界面展示使用的是一个数据对象。如果数据改变了,所有被反射的界面都会发生改变。对于值类型来说,同步更新是一个问题。
看到了不?类在任何使用引用类型而不是值类型的场景下是很有用的。
针对引用类型及值类型,可以看看这两个部分文章Reference vs. Value Types in Swift.
计算属性(Computed Properties)
所有有名类型都能让你创建自定义的setter和getter方法,这些方法不需要和存储的属性相关。
假设你希望添加一个直径的getter和setter方法到Circle类型中。通过已有的 radius 属性可以很简单的实现。
添加代码到你工作区的末尾:
extension Circle {
var diameter: Double {
get {
return radius * 2
}
set {
radius = newValue / 2
}
}
}
这样就实现了一个完全基于radius的新的计算属性。当你获取直径时,它会返回半径的两倍数。当你设置直径时,半径就会变成这个值的一半。简单~
很多时候,你仅仅希望实现一个特殊的getter方法。在这种情况下,你没必要包含get {}
关键字而仅仅需要指定实现体就行。面积和周长就是这种情况下的例子。
添加以下代码到你刚刚的扩展中:
// Example of getter-only computed properties
var area: Double {
return radius * radius * Double.pi
}
var perimeter: Double {
return 2 * radius * Double.pi
}
和类不同,结构体默认情况下不允许修改存储的属性,除非你声明为可更改的(mutating
)。
例如:添加如下代码到Circle中:
func shift(x: Double, y: Double) {
center.x += x
center.y += y
}
但是在修改center的x和y属性两行上出现了以下错误:
// ERROR: Left side of mutating operator has immutable type ‘Double'
// 错误:在不可变的Double类型上做了左+操作
通过添加mutating
可解决这个错误,像这样:
mutating func shift(x: Double, y: Double) {
center.x += x
center.y += y
}
这里就是告诉编译器这个函数可以修改结构体。
追溯建模和类型约束(Retroactive Modeling and Type Constraining)
Swift中一个非常棒的特性是追溯建模。它能够让你扩展一个数据类型即使你没有该类型的源码。
这里举个例子:假设你是SVG的使用者,你希望想Circle一样在Rectangle内添加 area
和 perimeter
属性。
为了说明这是什么意思,添加以下代码到工作区:
extension Rectangle {
var area: Double {
return size.width * size.height
}
var perimeter: Double {
return 2 * (size.width + size.height)
}
}
前面,你使用extension
来为一个已存在的模型添加方法,现在,你可以格式化这些方法成为一个协议。
添加如下代码至工作区:
protocol ClosedShape {
var area: Double { get }
var perimeter: Double { get }
}
这样就是一个正式的协议。
下一步,你让Circle和Rectangle采用 ClosedShape 协议,添加以下方法到你的工作区:
extension Circle: ClosedShape {}
extension Rectangle: ClosedShape {}
你可以定义一个函数,例如,计算一组采用 ClosedShape 协议的数据模型(任何诸如结构体,类,枚举类型)的周长。
添加如下代码到工作区:
func totalPerimeter(shapes: [ClosedShape]) -> Double {
return shapes.reduce(0) { $0 + $1.perimeter }
}
totalPerimeter(shapes: [circle, rectangle])
这里使用的是reduce来计算周长的总和。想了解更多关于它是如何工作的,请看 An Introduction to Functional Programming
何去何从
完整的工作区代码在这里
通过本教程,你了解了关于 enum, struct, class
--- Swift中有名类型。
三个的主要共性:都能提供封装,能有初始化方法,能有计算属性,能采用协议,能被追溯建模。
但是,他们也有重要的不同点。
枚举是值类型,有一系列的case,每种case能有不同的关联值。一个枚举类型的每个值代码一种枚举中定义的单个case。它们不能拥有存储的属性。
结构体,和枚举类似,是值类型但是能够拥有存储的属性。
类,类似于结构体,有存储的属性值,而且他们能够构建类层级,子类能够覆盖父类属性和方法。因此,显式的基类初始化是必须的。
和结构体及枚举不一样,类使用引用,也就是共享语义。
更多关于引用及值类型的知识,请看Reference vs. Value Types in Swift.
希望你能享受这段Swift有名类型的旋风旅行~ 如果你想要更多的挑战,你可以建造一个更加复杂的SVG版本。这是一个好的开始。:]