一、概述
面向协议编程.png在引入协议的扩展之后,苹果开始推动面向协议编程。即使是一种新的编程范式,它也迅速的在Swift编程和iOS开发中被广泛采用。这并不令人惊讶。面向协议编程(后文以POP代替)具有高度灵活性、许多优势。我最喜欢的应用程序之一是创建结构良好的网络请求,它就是基于POP的。
POP能够解决许多OOP的固有问题。而且,POP与Swift的值类型紧密配合,例如:结构体、枚举。
二、OOP存在的问题
面向对象编程是现代软件开发中最常见的范例。OOP远非完美,但它还解决了熟练的开发人员需要考虑的几个问题。要完全理解POP的多功能性,你首先需要了解面向对象编程所带来的问题。
2.1 POP可与任何设计模式(包括MVC)配合使用
作为本文的示例,我们将创建一个基于回合的小型幻想游戏。点我查看游戏源码。
这个小游戏提供了一个与iOS SDK其余部分隔离的面向协议编程的很好示例。 即使你不精通iOS开发,它也将包含许多你可以借鉴的想法。
但不要上当。
面向协议的编程不仅仅是仅适用于游戏开发的范例。 实际上,这对于普通的iOS开发是有好处的。
你可以在MVC模式的任何级别上使用面向协议的编程。 这里有些例子:
- 模型层
在模型层中,使用Codable解码JSON数据是面向协议编程的示例。 那不是唯一的。 整个Swift标准库都基于面向协议的编程。 例如,广泛的数据结构(例如字符串,数组和字典)符合复杂的协议网络。 - 模型控制器
在模型控制器级别,我通常使用面向协议的编程来表示网络请求和资源。 - VC层
由于在iOS中,所有视图控制器都必须继承自UIViewController类,因此使用简单继承很难在它们之间共享标准代码。 - 视图层
最后,视图也面临着视图控制器的相同挑战,因为它们也陷入了严格的继承结构中。 苹果公司的新UI框架SwiftUI是基于面向协议的程序设计的。
在继续之前,有一点免责声明。 我将要展示的内容不一定是制作游戏的最佳方法。 我使用此示例来满足本文的教学目的,而不是教你如何制作游戏。
2.2 在面向对象的编程中使用继承在类之间共享通用功能
许多编程语音,包含Swift,都会具有面向对象的特性,所以这是你首先学习和接触到的编程范式。
OOP是程序设计的重要组成部分,即使POP、FP具有某些优势,但OOP依然会存在。
尽管如此,OOP也有缺点。
为了展示它们,我们将开始使用类为游戏中的角色建模。
你发现“龙与地下城”风格游戏的第一种角色是战士。 战士是强大的战斗人员,可以使用各种各样的武器和盔甲。
struct Weapon: Equatable {
let name: String
let damage: Int
static let sword = Weapon(name: "Sword", damage: 15)
static let mace = Weapon(name: "Mace", damage: 10)
}
struct Armor: Equatable {
let name: String
let defense: Int
static let breastPlate = Armor(name: "Breastplate", defense: 10)
static let chainMail = Armor(name: "Chainmail", defense: 5)
}
class Warrior {
static let maxHealth = 140
let weapon: Weapon = .sword
let armor: Armor = .breastPlate
var health = maxHealth
}
译者注
Weapon、Armor都是结构体,而Warrior是类对象;也就是说Warrior是引用类型,其他的是值类型。为什么不都是值类型或者都是引用类型?
Weapon、Armor这两个类型目前看来都比较简单,使用值类型能够获得较高的性能收益。而Warrior,很可能被其他对象应用,即使有了写时复制,依然肯能带来较大损耗,所以使用引用类型。
上述解释,是针对OOP,如果使用POP,则是另外一种解法。
译者注
Weapon、Armor都遵循了Equatable协议,但是却没有实现Equatable协议要求的方法,这合法吗?
这是合法的!对于其成员都是符合Equatable协议的话,其类型本身会由编译器自动合成出此协议需要的方法,无需程序员手动实现。
下一类角色是巫师。 巫师不擅长战斗,但可以施放强大的咒语。 他们不能穿盔甲,因为它阻止了他们进行施法的必要动作。
不幸的是,我没有巫师的图片;但是我有女巫的图标,所以就用女巫。
struct Spell: Equatable {
let name: String
let power: Int
static let fireball = Spell(name: "Fireball", power: 30)
static let heal = Spell(name: "Heal", power: 20)
}
class Witch {
static let maxHealth = 110
let spell: Spell = .fireball
var health = maxHealth
}
目前为止,一切顺利。
现在,在我们的游戏中,各种角色之间需要互相交互。
- 战士可以同时攻击战士和女巫。 伤害取决于所使用的武器和被攻击角色的盔甲(如果有)。
- 巫婆也可以同时施放攻击战士和巫婆的咒语。 伤害取决于咒语的力量和被攻击角色的魔法抵御能力。
由于我们开始具有共同的特征,因此我们需要创建一个角色的超类。 这使战士和女巫的子类分别实现攻击和法术的方法。
class Character {
let maxHealth: Int
let defense: Int
let magicResistance: Int
var health: Int
init(maxHealth: Int, defense: Int, magicResistance: Int) {
self.maxHealth = maxHealth
self.health = maxHealth
self.defense = defense
self.magicResistance = magicResistance
}
}
class Warrior: Character {
let weapon: Weapon = .sword
let armor: Armor = .breastPlate
init() {
super.init(maxHealth: 140, defense: armor.defense, magicResistance: 0)
}
func attack(_ other: Character) {
let effect = max(0, weapon.damage - other.defense)
other.health -= effect
}
}
class Witch: Character {
let spell: Spell = .fireball
init() {
super.init(maxHealth: 110, defense: 0, magicResistance: 25)
}
func castSpell(on other: Character) {
let effect = max(0, spell.power - other.magicResistance)
switch spell {
case Spell.fireball: other.health -= effect
case Spell.heal: other.health = min(other.health + effect, other.maxHealth)
default: break
}
}
}
继承体系
2.3 问题:可以创建任何超类的空实例
乍一看,上面的代码看起来不错。
Character
类封装了通用属性,Warrior
和Witch
类可以分别初始化这些属性。 这些属性使我们能够定义Warrior
的Attack(_ :)
方法和Witch
类的castSpell(on :)
方法。
但是训练有素的眼睛已经可以发现一些问题。Character
是成熟的类(译者注:其是一个超类,不应该被实例化;但是其与可以实例化的子类,没有任何区别
)。 这意味着你可以创建它的实例,即使它在我们的游戏中没有任何意义。你可能认为你一开始就不会创建这样的实例。可能是。
但是你经常与其他开发人员一起从事大型项目。 他们还知道自己不应该这样做吗?编译器不会阻止这种情况
。
尽管在这个简单的示例中可能很明显,但是对于更复杂的超类却不那么明显。 然后,这些空实例会爬入代码的其他部分并造成问题。
例如,你可以创建UIViewController的实例。 但是,如果您了解iOS开发,那么你也将知道它是毫无意义的。 UIViewController仅存在的意义就是被子类化。
Java通过抽象类来避免此种情况,但是Swift没有类似的机制。
2.4 问题:子类承担初始化父类属性为有意义的值的职责
上面代码中还暗含着Warrior
、Witch
类必须为父类的maxHealth
、health
、defense
、magicResistance
提供初始化值的功能。
但是同样,编译器不会强制执行它。
相反,编译器仅强制我们将某些无用的代码放入Character类中。 我们必须使用无意义的值初始化所有存储的属性,否则我们的代码将无法编译。
如果我们在游戏中添加了新角色,而忘记了初始化任何这些属性,那么编译器将可以接受。 但是我们的新角色类将继承Character
的毫无意义的值,从而产生不一致的状态和奇怪的错误。
我们总是可以尝试结合使用计算出的属性,属性观察器和断言来解决这些问题。
但这只会增加额外的复杂性,而不会提供任何编译时检查。 我们将获得所有运行时崩溃,而我们可能无法在测试中捕获这些崩溃。
2.5 问题:子类可以打破超类不变性
要注意的另一件事是,在引入Character
类之前,将maxHealth
属性方便地实现为静态常量。这是有道理的。 每种角色类都有其自己的,预定义的值,该值永远不变。但是,现在,maxHealth
属性是一个变量。 这意味着即使在概念上是错误的,我们也可以随时在子类中更改其值。这是类不变性的一个示例,该条件始终需要为真。
继承的问题是,子类对超类的不变性一无所知。由于他们可以访问继承的存储属性,因此他们可以随时更改其值,从而有可能破坏层次结构中更高级别的超类的不变性。随着层次结构的深入,这种可能性越来越大。 同样,没有编译器检查。 我们唯一的防御方法是使用断言。
2.6 问题:不清楚何时可以安全地覆盖方法以及如何进行覆盖
现在让我们在游戏中引入一种新的角色类型:牧师。牧师是圣斗士,他们也可以施放从神那里获得的咒语。 这些通常是治疗法术。虽然神职人员可以战斗,但他们的装备受到限制。 他们需要使用较轻的盔甲来施放咒语,并且只能使用钝器。
class Cleric: Character {
let weapon: Weapon = .mace
let armor: Armor = .chainMail
let spell: Spell = .heal
init() {
super.init(maxHealth: 120, defense: armor.defense, magicResistance: 10)
}
}
牧师可以像战士一样战斗,因此它拥有武器,盔甲,并且需要与Warrior类相同的Attack(_ :)方法。 为了共享代码,我们然后创建一个Fighter超类。
class Fighter: Character {
let weapon: Weapon
let armor: Armor
init(maxHealth: Int, defense: Int, magicResistance: Int, weapon: Weapon, armor: Armor) {
self.armor = armor
self.weapon = weapon
super.init(maxHealth: maxHealth, defense: defense, magicResistance: magicResistance)
}
func attack(_ other: Character) {
let effect = max(0, weapon.damage - other.defense)
other.health -= effect
}
}
class Warrior: Fighter {
init() {
let weapon = Weapon.sword
let armor = Armor.breastPlate
super.init(maxHealth: 140, defense: armor.defense, magicResistance: 0, weapon: weapon, armor: armor)
}
}
class Cleric: Fighter {
let spell = Spell.heal
init() {
let weapon = Weapon.mace
let armor = Armor.chainMail
super.init(maxHealth: 120, defense: armor.defense, magicResistance: 10, weapon: weapon, armor: armor)
}
}
添加牧师后的类结构图
在这里,我们可以看到另外两个继承问题。
在我们的层次结构中添加更多的类会给初始化器增加新的要求。
- 每个级别为子类增加了更多的初始化负担。
- 现在,
Warrior
和Cleric
子类可以覆盖Attack(_ :)
方法。 但是他们应该吗? 如果这样做,他们应该依赖超类的实现,还是可以忽略它?在覆盖的开头,中间还是结尾?
同样,在我们的示例中,这些问题似乎微不足道,因为初始化程序仍然可以管理,并且Attack(_ :)
方法的实现很小。
但是,层次结构越深,越容易打破超类不变式,在错误的位置调用超类的方法或完全忘记它。
当你不拥有超类的代码并且无法阅读其实现时,情况将变得更糟。
你所剩下的只是猜测,希望触发一些断言,或者依靠文档,这些文档通常很差或根本不存在。 编译器没有任何帮助。
2.7 问题:OOP不支持多继承
译者注
大部分编程语言不支持多继承,但是C++支持!
由于神职人员也可以施放咒语,因此我们也希望在Cleric
和Witch
类之间共享代码。 因此,我们可以使用重复的代码创建一个新的Spellcaster类。
class Spellcaster: Character {
let spell: Spell
init(maxHealth: Int, defense: Int, magicResistance: Int, spell: Spell) {
self.spell = spell
super.init(maxHealth: maxHealth, defense: defense, magicResistance: magicResistance)
}
func castSpell(on other: Character) {
let effect = max(0, spell.power - other.magicResistance)
switch spell {
case Spell.fireball: other.health -= effect
case Spell.heal: other.health = min(other.health + effect, other.maxHealth)
default: break
}
}
}
class Witch: Spellcaster {
init() {
super.init(maxHealth: 110, defense: 0, magicResistance: 25, spell: .fireball)
}
}
这就是我们遇到的另一个面向对象编程的问题。
Swift允许一类仅从另一类继承。 这是因为在共同祖先和替代情况下,多重继承会产生歧义。
这就是所谓的菱形继承问题。 每种语言都以不同的方式解决它。 Swift和许多其他语言的解决方案是禁止类的多重继承。
因此,我们需要使Cleric
类同时来自Fighter
和Spellcaster
的唯一解决方案是将这两个类按层次结构排列,使其中一个相对于另一个下降。
这就产生了另一个问题。 不管您选择哪一个位于顶部,另一个都将不得不继承不需要的方法。
例如,如果我们使
Spellcaster
从Fighter
继承,它将继承武器和盔甲属性以及Attack(_ :)
方法。 而且,在传递性上,女巫类也将获得这些。这个问题被称为界面污染。
但是女巫无法战斗,因此我们不希望在女巫级别使用此功能。
对于继承的属性,我们唯一的解决方案是使它们成为可选属性。 这样,Witch类可以将武器和盔甲设置为零,因此我们进行了一些检查。
对于诸如
Attack(_ :)
之类的继承方法,我们唯一的选择是覆盖并使用断言。但是这些不是很好的解决方案。 再说一次,如果以后我们添加另一个源自
Spellcaster
的类,我们需要记住所有这些内容。
三、使用POP对业务领域建模
我们已经看到了面向对象编程带来的问题。尽管存在一些解决方案,但它们并不理想。 他们主要依靠程序员,而不是使用编译器来检查我们代码的正确性。面向协议的编程解决了许多此类问题,将负担从开发人员转移到了编译器。
3.1 面向协议编程的基础块:协议和协议扩展
顾名思义,面向协议的编程基于协议。 在Swift中,协议定义类型必须实现以适合特定任务或功能的所需接口。但是仅协议还不足以进行面向协议的编程。第二个功能是必需的:协议扩展。
在Swift中,你可以通过扩展将代码添加到任何类型,甚至可以添加到你不拥有的类型。 Swift 3将此功能扩展到了协议,该协议仅适用于该语言早期版本中的枚举,结构和类。这为我们在跨类型共享代码方面提供了很大的灵活性。
通过协议扩展,我们可以将方法实现附加到抽象协议上。 然后,符合该协议的所有类型都将继承这些方法。
这与面向对象编程中的继承没有什么不同。 但是由于协议的性质,我们可以摆脱面向对象编程所遇到的许多问题。
虽然面向协议的编程是一个主要指Swift的术语,但这一概念并不新鲜。 用更一般的编程术语来说,协议扩展是mixin,也可以使用其他语言。
3.2 不要听苹果的话:从具体的类型开始,而不是从协议开始
协议的一大特色是任何Swift类型都可以遵循它们。
Swift的值类型(即结构和枚举)没有类之类的继承机制。 但是值类型可以符合协议。 因此,由于有了协议扩展,它们仍然可以继承方法的实现。
这已经解决了面向对象编程的问题之一。 当我们传递值时,它们将被复制。 相反,对于对象,我们将引用传递给共享实例。
稍后这会很有用,但是我现在不得不提一下,因为那是我们将开始在游戏中引入面向协议的编程的地方。 我们将把我们的class变成结构。
但是,我们该如何进行呢? 我们要从结构还是协议开始?
在WWDC 2015演示中,Apple建议始终从协议开始。
但是我经常推荐相反的方法,并从具体类型开始,仅在需要时创建协议。 否则,最终会导致一堆协议,这些协议除了遵循准则外没有其他用途。
毕竟,当我们为游戏创建类时,我们并不是从超类开始的。 我们仅在需要共享代码时才创建后者。
实际上,甚至Apple在WWDC 2019的Modern Swift API设计会议上也改变了主意。
image.png
尽管如此,在本节中,我们将从协议开始。 但这仅仅是因为我们已经在上一节中完成了抽象工作。否则,我总是从结构开始。
3.3 协议声明要求而不是提供存储的属性
实现我们的游戏将需要大量的代码。 虽然我将在下面显示所有内容,但将掩盖其实现细节。我将只关注与理解面向协议编程有关的内容,否则本文将变成一本书。让我们开始为角色定义协议。 我们知道角色需要具有特定的属性,因此我们将其表示为协议中的要求。
protocol Character {
static var maxHealth: Int { get }
var health: Int { get set }
var magicResistance: Int { get }
var defense: Int { get }
}
extension Character {
var isDead: Bool {
return health <= 0
}
}
译者注
上述attack代码中,由于参数是不可变量,所以需要result这个临时变量
这些不是像我们的类中那样存储的属性。 相反,它们是任何符合标准的类型都必须满足的要求,并声明其自己的属性。 因为协议为每个属性定义了适当的访问权限,所以这使得更难打破不变式。
isDead属性是协议扩展的第一个示例。 在我们的游戏中,角色的生命值低于0时便死亡。任何符合Character的类型都将获得此计算属性。
3.4 协议层次结构比类层次结构浅
继续前面的示例的类层次结构,我们找到了Fighter类。 这也成为我们新方法中的协议。
protocol Fighter {
var weapon: Weapon { get }
var armor: Armor { get }
}
extension Fighter {
func attack(_ opponent: Character) -> Character {
var result = opponent
let effect = max(0, weapon.damage - opponent.defense)
result.health -= effect
return result
}
}
请注意,战士不再来继承自角色。为什么?因为不需要继承。
仅攻击的目标必须是角色。 攻击者只需要武器。 这使我们可以保持层次结构平坦,从而消除了类型的初始化负担。
因此,除非你需要,否则请不要使某个协议衍生自另一个协议。 这增加了可组合性。
这在我们的游戏中并不明显,因为毕竟所有战士都是角色。 但是稍后,您可能需要向游戏中添加新类型,例如怪物,神器和其他生物。
如果你希望这些攻击者能够攻击角色而又不像他们那样,则可以使它们符合Fighter协议,从而避免了Character的要求(该协议可能需要重命名)。
我们可以对Spellcaster协议执行相同的操作。
protocol Spellcaster {
var spell: Spell { get }
}
extension Spellcaster {
var isHealer: Bool {
return spell == .heal
}
func castSpell(on opponent: Character) -> Character {
var result = opponent
let effect = spell.power - opponent.magicResistance
result.health = isHealer
? min(opponent.health + effect, type(of: opponent).maxHealth)
: opponent.health - effect
return result
}
}
当我们实现游戏引擎时,isHealer属性将在以后有用。
3.5 遵循协议使你必须实现所有需求
我们现在终于可以实现:Warrior
, Witch
,Cleric
.
struct Warrior: Character, Fighter {
static let maxHealth = 140
let weapon: Weapon = .sword
let armor: Armor = .breastPlate
let magicResistance = 0
var health = maxHealth
var defense: Int {
return armor.defense
}
}
struct Witch: Character, Spellcaster {
static let maxHealth = 110
let spell: Spell = .fireball
let magicResistance = 25
let defense = 0
var health = maxHealth
}
struct Cleric: Character, Fighter, Spellcaster {
static let maxHealth = 100
let weapon: Weapon = .mace
let armor: Armor = .chainMail
let spell: Spell = .heal
let magicResistance = 10
var health = maxHealth
var defense: Int {
return armor.defense
}
}
这里最明显的好处是我们现在可以使用多重继承。 每个结构都遵循一个以上的协议,并继承其扩展中的所有属性和方法。
而且,没有忘记执行任何协议要求的风险,就像在类中发生的那样。 如果您忘记了一个,编译器将阻止您。
还要注意,maxHealth已恢复为静态属性。 在声明其他属性时,我们也有完全的自由。
例如,在
Warrior
和Cleric
类型中,防御属性是根据盔甲而计算的,而在Witch
结构中则是固定存储的属性。最后,我们只能从这三种类型创建值。 无法使用“角色”,“战士”或“咒语施法者”类型创建值,这是没有意义的。
四、扩展现有类型和层次结构而无需更改它们
面向协议的编程不仅有助于创建新的分类法。 协议和协议扩展的另一个优点是,可以在不更改现有类型的情况下模块化地扩展任何层次结构。
4.1 POP并不是解决每个问题的神奇方法
我们有角色,但我们还没有游戏。 为此,我们需要创建一些规则。
我不会假装这将是App Store中最好的游戏。 同样,我的目的是向你展示面向协议的编程实践。
如果你有兴趣创建引人入胜的游戏,请访问此网站,了解游戏设计。
我们的游戏规则很简单:
- 游戏由两支各有五个角色的团队进行比赛(一个团队将由稍后将实现的简单AI算法控制)。
- 一个团队中的角色是随机选择的,但是每个团队中每种类型至少要有一个角色。
- 游戏轮流交替进行,每个团队都可以移动。
- 每个角色每个回合都会获得一个动作。 当所有角色都移动时,回合结束。
从上述规则看来,每个团队的成员都需要一些新属性。 - 我们需要一种单独识别每个团队成员的方法,因为每个团队中每个类型的角色都可能超过一个。
- 我们需要知道团队成员在本轮比赛中何时进行了移动。
由于我们都对面向协议的编程充满热情,因此诱惑可能是创建具有这些要求的新协议。
俗话说:“当你只有锤子时,一切都像钉子。”
但是,面向协议的编程并不是解决每个问题的神奇解决方案。 我们的结构代表了角色的行为。
相反,游戏规则不属于角色。 我们可以在各种具有不同规则的游戏中使用这些类型。
因此,在这种情况下,组合比继承更好。
struct Game {
var turn = Team.player
mutating func startNewRound() {
for var member in members {
member.didMove = false
update(member)
}
}
}
extension Game {
enum Team {
case player
case opponent
}
struct TeamMember {
let id = UUID()
let team: Team
var character: Character
var didMove = false
}
}
游戏结构是我们执行游戏规则的地方。 Team和TeamMember类型具有名称空间,以明确它们属于Game实现。
TeamMember具有用于合成的角色属性。 我们可以将角色用作此属性的通用类型,因为这是我们对团队成员的唯一要求。
4.2 使用协议一致性作为泛型函数的要求
现在我们需要为游戏创建初始条件。 为此,我们需要一个函数来随机化每个团队中的成员,确保每种类型的每一侧至少获得一个角色。
struct Game {
var turn = Team.player
var members = makeTeam(.player) + makeTeam(.opponent)
var playerTeam: [TeamMember] {
return members.filter { $0.team == .player }
}
var opponentTeam: [TeamMember] {
return members.filter { $0.team == .opponent }
}
mutating func startNewRound() {
for var member in members {
member.didMove = false
update(member)
}
}
}
private extension Game {
static func makeTeam(_ team: Team) -> [TeamMember] {
let witches = (0 ..< Int.random(in: 1...2)).map { _ in Witch() }
let clerics = (0 ..< Int.random(in: 1...2)).map { _ in Cleric() }
let teamCount = 5
let remaining = teamCount - witches.count - clerics.count
let warriors: [Warrior] = (0 ..< Int.random(in: remaining...remaining)).map { _ in Warrior() }
let allCharacters: [Character] = witches + clerics + warriors
return allCharacters
.shuffled()
.map { TeamMember(team: team, character: $0, didMove: false) }
}
}
除了一件事,这是一个非常有趣的功能。
注意创建随机数量的女巫,战士和牧师的代码是如何重复的。 我们需要对其进行抽象以使其可重用。
但是,问题在于每一行都明确使用角色类型。 要抽象此代码,我们需要一种使用类型作为参数的方法。
这是我们可以使用协议再次执行的操作。
private extension Game {
static func makeTeam(_ team: Team) -> [TeamMember] {
func makeCharacters<C: Initializable>(from: Int, upTo: Int) -> [C] {
return (0 ..< Int.random(in: from...upTo)).map { _ in C.init() }
}
let witches: [Witch] = makeCharacters(from: 1, upTo: 2)
let clerics: [Cleric] = makeCharacters(from: 1, upTo: 2)
let teamCount = 5
let remaining = teamCount - witches.count - clerics.count
let warriors: [Warrior] = makeCharacters(from: remaining, upTo: remaining)
let allCharacters: [Character] = witches + clerics + warriors
return allCharacters
.shuffled()
.map { TeamMember(team: team, character: $0, didMove: false) }
}
}
protocol Initializable {
init()
}
extension Warrior: Initializable {}
extension Witch: Initializable {}
extension Cleric: Initializable {}
初始化协议要求一致的类型具有不带参数的初始化器。 多亏了它,我们然后可以使用带有Initializable类型约束的C Swift泛型来创建makeCharacters(from:upTo :)泛型函数。
(旁注:我将makeCharacters(from:upTo :)做成了一个嵌套函数,因为这是我们唯一需要的地方。如果愿意,可以将其设为通常的私有方法。)
然后使用扩展名,使每个角色类型都符合Initializable。
这是面向协议编程的另一个优点。 我们可以使任何已经定义的类型符合协议,而无需更改其声明。
出于以下几个原因,这很有用:
- 我们可以为任何类型添加一致性,甚至可以为我们不拥有的类型添加一致性,无论它们属于iOS SDK还是第三方框架。
- 我们可以明确指定哪些类型符合我们的协议,从而使编译器可以进行更严格的检查。
makeCharacters(from:upTo :)
不接受带有init()
初始化程序的任何类型。 它仅接受可初始化类型。 - 我们不需要更改我们的类型的声明。 这使他们的代码更易于阅读,并且可以跨代码库重复使用。 只有我们的游戏结构才需要可初始化协议。 我们可以导出和重用角色类型,而无需携带它。
4.3 通过协议一致性和扩展性为类型添加额外的功能
我们在游戏定义中需要做的最后一件事是执行动作的方法。
一个动作需要:
- 一种行为;
- 角色扮演;
- 目标。
每个角色可以执行通过各种方法表示的不同动作。 但是,尽管它们的签名不同,但每种方法都是从一个角色到另一个角色的函数。 因此,我们可以声明要在存储的属性中存储泛型函数的操作。
我们之所以这样做,是因为在Swift函数中,函数是一等公民,这意味着我们可以像对待其他任何类型一样对待它们。 我们可以将它们存储在变量中,并将它们作为参数传递给其他函数。
这是一个函数式编程概念,因此不在本文讨论范围之内。
struct Game {
var turn = Team.player
var members = makeTeam(.player) + makeTeam(.opponent)
var playerTeam: [TeamMember] {
return members.filter { $0.team == .player }
}
var opponentTeam: [TeamMember] {
return members.filter { $0.team == .opponent }
}
mutating func perform(_ move: Move) {
func update(_ member: TeamMember) {
guard let index = members.firstIndex(where: { $0.id == member.id }) else { return }
members[index] = member
}
func startNewRound() {
for var member in members {
member.didMove = false
update(member)
}
}
var teamMember = move.performer
teamMember.didMove = true
update(teamMember)
var target = move.target.id == teamMember.id ? teamMember : move.target
target.character = move.action.closure(target.character)
update(target)
turn = turn.other
let roundEnded = members.filter({ !$0.didMove }).isEmpty
if roundEnded {
startNewRound()
}
}
}
extension Game {
enum Team {
case player
case opponent
}
struct TeamMember {
let id = UUID()
let team: Team
var character: Character
var didMove = false
}
struct Action {
let name: String
let closure: (Character) -> Character
let isAttack: Bool
}
struct Move {
let performer: TeamMember
let target: TeamMember
let action: Action
}
}
现在,我们需要从每个角色中提取可用的动作。 为此,我们将再次使用面向协议的编程。
首先,我们定义一个协议,该协议需要一个符合类型的协议才能返回操作数组。 然后,我们可以通过扩展使我们的类型符合该协议。
protocol Actionable {
var actions: [Game.Action] { get }
}
extension Fighter {
var attackAction: Game.Action {
return Game.Action(name: "Attack", closure: attack(_:), isAttack: true)
}
}
extension Spellcaster {
var spellAction: Game.Action {
return Game.Action(name: spell.name, closure: castSpell(on:), isAttack: !isHealer)
}
}
extension Warrior: Actionable {
var actions: [Game.Action] {
return [attackAction]
}
}
extension Witch: Actionable {
var actions: [Game.Action] {
return [spellAction]
}
}
extension Cleric: Actionable {
var actions: [Game.Action] {
return [attackAction, spellAction]
}
}
注意,这再次解决了一些面向对象编程的问题。
Actionable
强制任何符合条件的类型来实现actions
属性。 添加新类型时,你不会忘记实现。
动作是必需的,而不是像超类那样的继承函数,因此我们没有覆盖的问题。 我们不需要怀疑是否可以覆盖需求,或者是否以及在何处需要调用超级实现。
协议要求不会造成接口污染。 任何符合Actionable的类型都需要action
属性,因此它绝不会以不需要它的类型结尾。
我们仍然可以将方法添加到Fighter
和Spellcaster
中,这些方法将由一致的类型继承。 关于覆盖的所有注意事项仍然适用。
这样就完成了我们的游戏逻辑的实现。请注意,现在我们的结构属于更广泛的继承层次结构。
这种层次结构比面向对象的层次结构更平坦,更灵活。 此外,我们使用扩展创建了它的一部分。
这意味着我们可以仅将层次结构的一部分移至其他项目,而无需携带所有协议。 对于类层次结构,这是不可能的。
五、实现可重用的泛型算法
面向协议的程序设计的另一个重要用途是能够实现与通用数据结构和实现完全脱钩的标准通用算法。 一个算法需要的只是一些协议要求。 然后,通过遵守这些规定,可以在任何类型上使用这些算法实现。
5.1 使用minimax算法实现游戏AI
现在,我们将编写一个简单的AI算法,即使你没有朋友也可以玩我们的游戏。
典型的游戏AI算法是minimax,它用于各种对抗游戏,例如国际象棋。
从概念上讲,minimax并不难理解。 它所做的就是为一名玩家尝试所有可能的动作,然后选择最佳的动作。 为了评估一个动作的好坏,minimax使用启发式函数来知道该动作后游戏状态的好坏。
当然,在任何战略游戏中,没有任何举动是孤立的。 如果您曾经下过国际象棋,那么你就会知道,如果对手的皇后下一步要与你对战,那么捕获女王就没有任何意义。
因此,该算法以递归方式评估运动。 采取行动后,它将考虑所有可能的答复,然后考虑对这些答复的所有可能的答复,依此类推,直到找到获胜位置。
当然,该算法假定每个玩家将始终选择可用的最佳动作。 希望因为对手做出愚蠢的举动而获胜是一个错误的策略。
这意味着,在每个步骤中,算法都会尝试最大化一个玩家的启发式值,并最小化另一玩家的启发式值。 这就是算法名称的来源。
可以用几行伪代码来概括该算法,我从其Wikipedia页面中提取了该伪代码:
extension Minimaxable {
func minimax(atDepth depth: Int, maximizingPlayer: Bool) -> EvaluatedPly<Ply> {
if depth == 0 || isTerminal {
return EvaluatedPly(value: heuristicValue(maximizingPlayer: maximizingPlayer), ply: nil)
}
var bestPly: EvaluatedPly<Ply> = EvaluatedPly(value: maximizingPlayer ? Int.min : Int.max, ply: nil)
for ply in possiblePlies() {
let state = self.state(performing: ply)
let evaluatedPly = state.minimax(atDepth: depth - 1, maximizingPlayer: !maximizingPlayer)
let currentPly = EvaluatedPly(value: evaluatedPly.value, ply: ply)
bestPly = maximizingPlayer ? max(currentPly, bestPly) : min(currentPly, bestPly)
}
return bestPly
}
func nextPly() -> Ply {
let evaluatedPly = minimax(atDepth: 3, maximizingPlayer: true)
return evaluatedPly.ply!
}
}
最后一个警告。
Minimax具有指数时间复杂度。 指数时间算法既昂贵又实用,仅适用于少量数据。
因此,该算法无法始终始终下降到胜利位置。 对于玩家有许多可能动作的游戏,即使在最快的计算机上也无法达到最终状态。
因此,该算法需要一个额外的深度参数,该参数会在每个步骤中减小。 当深度达到0时,算法停止并返回当前状态的启发式值。
5.2 协议对于抽象通用算法的需求很有用
当我们处理诸如minimax之类的通用算法时,保持代码通用且不受实现细节的影响非常有用。 这使代码更具可读性,并且下次创建游戏时,可以直接重用该算法。
因此,尽管上面有我所有的建议,但还是最好从协议开始(在软件开发中,没有绝对的规则可言)。
为了能够实现minimax算法,我们需要:
- 知道位置是否处于终点,即玩家何时获胜且游戏结束;
- 查找启发式函数以评估游戏状态;
- 查找处于任何给定状态下的球员(称为板层)的所有可能动作;
- 将每个层应用于游戏状态,得到一个新的结果状态。
同样,我们在协议中表达所有这些要求。
protocol Minimaxable {
associatedtype Ply
var isTerminal: Bool { get }
func heuristicValue(maximizingPlayer: Bool) -> Int
func possiblePlies() -> [Ply]
func state(performing move: Ply) -> Self
}
注意,该协议的要求都是通用的。 所有类型都是Bool和Int之类的通用类型,或者是Self和Ply之类的泛型类型。 我没有使用来自我们游戏结构的任何特定类型。
我们仍然不知道如何为我们的游戏类型实现这些要求,但这并不重要。 我们已经可以依靠这些要求来实现minimax算法。
5.3 仅根据协议要求实施通用算法
上面的minimax算法的伪代码缺少重要的细节。
以这种形式,它所做的就是返回游戏状态的启发式值。 但是,在玩游戏时,我们并不仅仅在乎那个价值。 我们想知道哪一步是最好的。
如果遵循该蓝图,则必须为第一步生成所有可能的层,在每个层上调用算法,然后选择值最高的层。
那行得通,但是会有点多余,因为minimax算法已经包含一个精确执行此操作的循环。 因此,我更喜欢对算法进行调整,使其不仅返回移动的值,而且还返回移动。
为此,我们需要一种新的类型。
struct EvaluatedPly<P> {
let value: Int
let ply: P?
}
extension EvaluatedPly: Comparable {
static func < (lhs: EvaluatedPly<P>, rhs: EvaluatedPly<P>) -> Bool {
return lhs.value < rhs.value
}
static func == (lhs: EvaluatedPly<P>, rhs: EvaluatedPly<P>) -> Bool {
return lhs.value == rhs.value
}
}
这是面向协议编程的另一个实例。
minimax
算法使用min(_:_ :)
和max(_:_ :)
函数,在Swift中它们仅对符合Comparable
的类型起作用。 Swift标准库中的许多类型已经符合它,包括Int
,String
和Date
。
使EvaluatedPly
符合Comparable
将使我们能够使用min(_:_ :)
和max(_:_ :)
函数,以及可与可比较类型一起使用的任何其他函数。
最后,实现minimax
算法只是将上述伪代码转换为Swift代码的问题。
extension Minimaxable {
func minimax(atDepth depth: Int, maximizingPlayer: Bool) -> EvaluatedPly<Ply> {
if depth == 0 || isTerminal {
return EvaluatedPly(value: heuristicValue(maximizingPlayer: maximizingPlayer), ply: nil)
}
var bestPly: EvaluatedPly<Ply> = EvaluatedPly(value: maximizingPlayer ? Int.min : Int.max, ply: nil)
for ply in possiblePlies() {
let state = self.state(performing: ply)
let evaluatedPly = state.minimax(atDepth: depth - 1, maximizingPlayer: !maximizingPlayer)
let currentPly = EvaluatedPly(value: evaluatedPly.value, ply: ply)
bestPly = maximizingPlayer ? max(currentPly, bestPly) : min(currentPly, bestPly)
}
return bestPly
}
func nextPly() -> Ply {
let evaluatedPly = minimax(atDepth: 3, maximizingPlayer: true)
return evaluatedPly.ply!
}
}
我的代码结构略有不同,因为if-else语句的两个分支看起来很多余,我将它们合并为一个。 除此之外,我的代码一对一地匹配伪代码。
minimax(atDepth:maximizingPlayer :)
方法是一种递归方法。 我在有关函数式编程的文章中深入讨论了递归。
5.4 使类型符合协议以继承通用算法实现
现在,我们可以使我们的Game
类型符合Minimaxable
,并利用通用的minimax
实现。
它的两个要求很容易实现:
当任一队都没有活着的成员时,比赛状态即为终局。
在我们的游戏中,层具有Game.Move
结构,并且我们的Game
类型已经具有执行移动的方法,我们可以使用该方法。
extension Game: Minimaxable {
var isTerminal: Bool {
return playerTeam.count == 0 || opponentTeam.count == 0
}
func state(performing ply: Game.Move) -> Game {
var state = self
state.perform(move)
return state
}
}
在这里,我们可以看到我尚未讨论的面向协议编程的另一个优点。
由于我们的游戏结构是一种值类型,因此我们可以在state(performing :)
方法中快速创建副本,然后对其进行移动。
如果游戏类型是类,则任何移动都会改变实际游戏的状态。 为了能够实现state(performing :)方法,我们必须编写代码来执行Game实例的深层副本。
节点的启发式值取决于游戏规则。 对于我们的游戏,我们可以将一个团队中所有角色的生命值相加,然后减去另一个角色中角色的生命值。
extension Game: Minimaxable {
var isTerminal: Bool {
return playerTeam.count == 0 || opponentTeam.count == 0
}
func heuristicValue(maximizingPlayer: Bool) -> Int {
var healthScore = 0
for member in members.filter({ !$0.character.isDead }) {
let maximizingTeam = maximizingPlayer ? self.turn : self.turn.other
let sign = member.team == maximizingTeam ? 1 : -1
healthScore += sign * member.character.health
}
return healthScore
}
func state(performing ply: Game.Move) -> Game {
var state = self
state.perform(move)
return state
}
}
这不是一个完美的指标,但是它起作用了,因为该算法将尝试使对对手的伤害最大化。
由于启发式算法,该算法无需了解所有规则。 伤害基于攻击,防御,力量和魔抗值,但它们不在启发式实现中。 尝试所有动作并检查结果已经说明了这些游戏规则。
更为微妙的启发式方法还会考虑其他因素,例如,活着的角色数,因为更少的角色意味着一轮中可供玩家使用的动作更少。
如果您对平衡和评估游戏规则感兴趣,请访问此网站。
Minimaxable
的最后一个要求是possiblePlies()
方法。 在这里,我们可以利用我们的字符结构符合Actionable
协议的事实来编写通用方法。
extension Game: Minimaxable {
var isTerminal: Bool {
return playerTeam.count == 0 || opponentTeam.count == 0
}
func heuristicValue(maximizingPlayer: Bool) -> Int {
var healthScore = 0
for member in members.filter({ !$0.character.isDead }) {
let maximizingTeam = maximizingPlayer ? self.turn : self.turn.other
let sign = member.team == maximizingTeam ? 1 : -1
healthScore += sign * member.character.health
}
return healthScore
}
func state(performing ply: Game.Move) -> Game {
var state = self
state.perform(move)
return state
}
func possiblePlies() -> [Game.Move] {
let attacker = turn == .player ? playerTeam : opponentTeam
let defender = turn == .player ? opponentTeam : playerTeam
var moves: [Game.Move] = []
for member in attacker {
guard !member.didMove else { continue }
for action in member.actions {
moves += action.isAttack
? defender.map { Move(performer: member, target: $0, action: action) }
: attacker.map { Move(performer: member, target: $0, action: action) }
}
}
return moves
}
}
如果没有Actionable,我们将不得不将每个字符转换为具体的类型,然后调用适当的方法。
那将意味着冗长的条件语句,违反了开放式封闭原则,这是面向协议编程的又一个优势。
六、结论
我不会讨论游戏的UI实施,因为它与面向协议的编程无关。 如果您有兴趣查看代码,可以在GitHub上的完整Xcode项目中找到它。
一旦实现了UI,您就可以与AI玩我们的小游戏。
游戏示意
诚然,这不是您将要玩的最激动人心的iOS游戏,但是考虑到我们编写的代码很少,它已经具有可玩性,这已经令人印象深刻。
在本文中,我向您展示了面向协议的编程解决了许多面向对象编程的问题。 因此,它是所有认真的iOS开发者工具箱中的必备工具。
但是,这并不意味着您可以将面向对象的编程扔到窗外。
iOS SDK的很大一部分是面向对象的,包括iOS开发中最常用的框架UIKit和Foundation。 视图,视图控制器,用户默认设置,URL会话以及Apple框架中的许多其他类型都是类。
SwiftUI稍微改善了这种情况。 它的由结构组成的声明性语法完全基于面向协议的编程。 但是,即使使用它代替UIKit,也仍然需要创建观察对象和环境对象,并且仍然需要使用Foundation和其他框架。
应用程序体系结构中总是需要共享和全局访问的部分。 这些只能用引用类型实现。 因此,您仍然必须在项目中使用类。
尽管如此,面向协议的编程也可以与类一起使用。 不仅限于结构之类的价值类型。 因此,即使必须将类用于某些任务,您仍然可以在达到类继承之前使用协议和扩展来缓解一些面向对象编程的问题。
网友评论