如何写一手漂亮的模型:面向对象编程的设计原则综述
选自Medium,机器之心编译。
面向对象的编程在实现想法乃至系统的过程中都非常重要,我们不论是使用 TensorFlow 还是 PyTorch 来构建模型都或多或少需要使用类和方法。而采用类的方法来构建模型会令代码非常具有可读性和条理性,本文介绍了算法实现中使用类和方法来构建模型所需要注意的设计原则,它们可以让我们的机器学习代码更加美丽迷人。
大多数现代编程语言都支持并且鼓励面向对象编程(OOP)。即使我们最近似乎看到了一些偏离,因为人们开始使用不太受 OOP 影响的编程语言(例如 Go, Rust, Elixir, Elm, Scala),但是大多数还是具有面向对象的属性。我们在这里概括出的设计原则也适用于非 OOP 编程语言。
为了成功地写出清晰的、高质量的、可维护并且可扩展的代码,我们需要以 Python 为例了解在过去数十年里被证明是有效的设计原则。
对象类型
因为我们要围绕对象来建立代码,所以区分它们的不同责任和变化是有用的。一般来说,面向对象的编程有三种类型的对象。
1. 实体对象
这类对象通常对应着问题空间中的一些现实实体。比如我们要建立一个角色扮演游戏(RPG),那么简单的 Hero 类就是一个实体对象。
class Hero:
def __init__(self, health, mana):
self._health = health
self._mana = mana
def attack(self) -> int:
"""
Returns the attack damage of the Hero
"""
return 1
def take_damage(self, damage: int):
self._health -= damage
def is_alive(self):
return self._health > 0 复制代码
这类对象通常包含关于它们自身的属性(例如 health 或 mana),这些属性根据具体的规则都是可修改的。
2. 控制对象(Control Object)
控制对象(有时候也称作管理对象)主要负责与其它对象的协调,这是一些管理并调用其它对象的对象。我们上面的 RPG 案例中有一个很棒的例子,Fight 类控制两个英雄,并让它们对战。
class Fight:
class FightOver(Exception):
def __init__(self, winner, *args, **kwargs):
self.winner = winner
super(*args, **kwargs)
def __init__(self, hero_a: Hero, hero_b: Hero):
self._hero_a = hero_a
self._hero_b = hero_b
self.fight_ongoing = True
self.winner = None
def fight(self):
while self.fight_ongoing:
self._run_round()
print(f'The fight has ended! Winner is #{self.winner}')
def _run_round(self):
try:
self._run_attack(self._hero_a, self._hero_b)
self._run_attack(self._hero_b, self._hero_a)
except self.FightOver as e:
self._finish_round(e.winner)
def _run_attack(self, attacker: Hero, victim: Hero):
damage = attacker.attack()
victim.take_damage(damage)
if not victim.is_alive():
raise self.FightOver(winner=attacker)
def _finish_round(self, winner: Hero):
self.winner = winner
self.fight_ongoing = False
复制代码
在这种类中,为对战封装编程逻辑可以给我们提供多个好处:其中之一就是动作的可扩展性。我们可以很容易地将参与战斗的英雄传递给非玩家角色(NPC),这样它们就能利用相同的 API。我们还可以很容易地继承这个类,并复写一些功能来满足新的需要。
3. 边界对象(Boundary Object)
这些是处在系统边缘的对象。任何一个从其它系统获取输入或者给其它系统产生输出的对象都可以被归类为边界对象,无论那个系统是用户,互联网或者是数据库。
class UserInput:
def __init__(self, input_parser):
self.input_parser = input_parser
def take_command(self):
"""
Takes the user's input, parses it into a recognizable command and returns it
"""
command = self._parse_input(self._take_input())
return command def _parse_input(self, input): return self.input_parser.parse(input) def _take_input(self): raise NotImplementedError() class UserMouseInput(UserInput): pass class UserKeyboardInput(UserInput): pass class UserJoystickInput(UserInput): pass 复制代码
这些边界对象负责向系统内部或者外部传递信息。例如对要接收的用户指令,我们需要一个边界对象来将键盘输入(比如一个空格键)转换为一个可识别的域事件(例如角色的跳跃)。
Bonus:值对象(Value Object)
价值对象代表的是域(domain)中的一个简单值。它们无法改变,不恒一。
如果将它们结合在我们的游戏中,Money 类或者 Damage 类就表示这种对象。上述的对象让我们容易地区分、寻找和调试相关功能,然而仅使用基础的整形数组或者整数却无法实现这些功能。
class Money:
def __init__(self, gold, silver, copper):
self.gold = gold
self.silver = silver
self.copper = copper
def __eq__(self, other):
return self.gold == other.gold and self.silver == other.silver and self.copper == other.copper
def __gt__(self, other):
if self.gold == other.gold and self.silver == other.silver:
return self.copper > other.copper
if self.gold == other.gold:
return self.silver > other.silver return self.gold > other.gold def __add__(self, other): return Money(gold=self.gold + other.gold, silver=self.silver + other.silver, copper=self.copper + other.copper) def __str__(self): return f'Money Object(Gold: {self.gold}; Silver: {self.silver}; Copper: {self.copper})' def __repr__(self): return self.__str__() print(Money(1, 1, 1) == Money(1, 1, 1)) # => True print(Money(1, 1, 1) > Money(1, 2, 1)) # => False print(Money(1, 1, 0) + Money(1, 1, 1)) # => Money Object(Gold: 2; Silver: 2; Copper: 1) 复制代码
它们可以归类为实体对象的子类别。
关键设计原则
设计原则是软件设计中的规则,过去这些年里已经证明它们是有价值的。严格地遵循这些原则有助于软件达到一流的质量。
抽象(Abstraction)
抽象就是将一个概念在一定的语境中简化为原始本质的一种思想。它允许我们拆解一个概念来更好的理解它。
上面的游戏案例阐述了抽象,让我们来看一下 Fight 类是如何构建的。我们以尽可能简单的方式使用它,即在实例化的过程中给它两个英雄作为参数,然后调用 fight() 方法。不多也不少,就这些。
代码中的抽象过程应该遵循最少意外(POLA)的原则,抽象不应该用不必要和不相关的行为/属性。换句话说,它应该是直观的。
注意,我们的 Hero#take_damage() 函数不会做一些异常的事情,例如在还没死亡的时候删除角色。但是如果他的生命值降到零以下,我们可以期望它来杀死我们的角色。
封装
封装可以被认为是将某些东西放在一个类以内,并限制了它向外部展现的信息。在软件中,限制对内部对象和属性的访问有助于保证数据的完整性。
将内部编程逻辑封装成黑盒子,我们的类将更容易管理,因为我们知道哪部分可以被其它系统使用,哪些不行。这意味着我们在保留公共部分并且保证不破坏任何东西的同时能够重用内部逻辑。此外,我们从外部使用封装功能变得更加简单,因为需要考虑的事情也更少。
在大多数编程语言中,封装都是通过所谓的 Access modifiers(访问控制修饰符)来完成的(例如 private,protected 等等)。Python 并不是这方面的最佳例子,因为它不能在运行时构建这种显式修饰符,但是我们使用约定来解决这个问题。变量和函数前面的_前缀就意味着它们是私有的。
举个例子,试想将我们的 Fight#_run_attack 方法修改为返回一个布尔变量,这意味着战斗结束而不是发生了意外。我们将会知道,我们唯一可能破坏的代码就是 Fight 类的内部,因为我们是把这个函数设置为私有的。
请记住,代码更多的是被修改而不是重写。能够尽可能清晰、较小影响的方式修改代码对开发的灵活性很重要。
分解
分解就是把一个对象分割为多个更小的独立部分,这些独立的部分更易于理解、维护和编程。
试想我们现在希望 Hero 类能结合更多的 RPG 特征,例如 buffs,资产,装备,角色属性。
class Hero:
def __init__(self, health, mana):
self._health = health
self._mana = mana
self._strength = 0
self._agility = 0
self._stamina = 0
self.level = 0
self._items = {}
self._equipment = {}
self._item_capacity = 30
self.stamina_buff = None
self.agility_buff = None
self.strength_buff = None
self.buff_duration = -1
def level_up(self):
self.level += 1
self._stamina += 1
self._agility += 1
self._strength += 1
self._health += 5
def take_buff(self, stamina_increase, strength_increase, agility_increase):
self.stamina_buff = stamina_increase
self.agility_buff = agility_increase
self.strength_buff = strength_increase
self._stamina += stamina_increase
self._strength += strength_increase
self._agility += agility_increase
self.buff_duration = 10 # rounds
def pass_round(self):
if self.buff_duration > 0:
self.buff_duration -= 1
if self.buff_duration == 0: # Remove buff
self._stamina -= self.stamina_buff
self._strength -= self.strength_buff
self._agility -= self.agility_buff
self._health -= self.stamina_buff * 5
self.buff_duration = -1
self.stamina_buff = None
self.agility_buff = None
self.strength_buff = None
def attack(self) -> int:
""" Returns the attack damage of the Hero """ return 1 + (self._agility * 0.2) + (self._strength * 0.2) def take_damage(self, damage: int): self._health -= damage def is_alive(self): return self._health > 0 def take_item(self, item: Item): if self._item_capacity == 0: raise Exception('No more free slots') self._items[item.id] = item self._item_capacity -= 1 def equip_item(self, item: Item): if item.id not in self._items: raise Exception('Item is not present in inventory!') self._equipment[item.slot] = item self._agility += item.agility self._stamina += item.stamina self._strength += item.strength self._health += item.stamina * 5 # 缺乏分解的案例 复制代码
我们可能会说这份代码已经开始变得相当混乱了。我们的 Hero 对象一次性设置了太多的属性,结果导致这份代码变得相当脆弱。
例如,我们的耐力分数为 5 个生命值,如果将来要修改为 6 个生命值,我们就要在很多地方修改这个实现。
解决方案就是将 Hero 对象分解为多个更小的对象,每个小对象可承担一些功能。下面展示了一个逻辑比较清晰的架构:
from copy import deepcopy
class AttributeCalculator:
@staticmethod
def stamina_to_health(self, stamina):
return stamina * 6
@staticmethod
def agility_to_damage(self, agility):
return agility * 0.2
@staticmethod
def strength_to_damage(self, strength):
return strength * 0.2
class HeroInventory:
class FullInventoryException(Exception):
pass
def __init__(self, capacity):
self._equipment = {}
self._item_capacity = capacity
def store_item(self, item: Item):
if self._item_capacity < 0:
raise self.FullInventoryException()
self._equipment[item.id] = item
self._item_capacity -= 1
def has_item(self, item):
return item.id in self._equipment class HeroAttributes: def __init__(self, health, mana): self.health = health self.mana = mana self.stamina = 0 self.strength = 0 self.agility = 0 self.damage = 1 def increase(self, stamina=0, agility=0, strength=0): self.stamina += stamina self.health += AttributeCalculator.stamina_to_health(stamina) self.damage += AttributeCalculator.strength_to_damage(strength) + AttributeCalculator.agility_to_damage(agility) self.agility += agility self.strength += strength def decrease(self, stamina=0, agility=0, strength=0): self.stamina -= stamina self.health -= AttributeCalculator.stamina_to_health(stamina) self.damage -= AttributeCalculator.strength_to_damage(strength) + AttributeCalculator.agility_to_damage(agility) self.agility -= agility self.strength -= strength class HeroEquipment: def __init__(self, hero_attributes: HeroAttributes): self.hero_attributes = hero_attributes self._equipment = {} def equip_item(self, item): self._equipment[item.slot] = item self.hero_attributes.increase(stamina=item.stamina, strength=item.strength, agility=item.agility) class HeroBuff: class Expired(Exception): pass def __init__(self, stamina, strength, agility, round_duration): self.attributes = None self.stamina = stamina self.strength = strength self.agility = agility self.duration = round_duration def with_attributes(self, hero_attributes: HeroAttributes): buff = deepcopy(self) buff.attributes = hero_attributes return buff def apply(self): if self.attributes is None: raise Exception() self.attributes.increase(stamina=self.stamina, strength=self.strength, agility=self.agility) def deapply(self): self.attributes.decrease(stamina=self.stamina, strength=self.strength, agility=self.agility) def pass_round(self): self.duration -= 0 if self.has_expired(): self.deapply() raise self.Expired() def has_expired(self): return self.duration == 0 class Hero: def __init__(self, health, mana): self.attributes = HeroAttributes(health, mana) self.level = 0 self.inventory = HeroInventory(capacity=30) self.equipment = HeroEquipment(self.attributes) self.buff = None def level_up(self): self.level += 1 self.attributes.increase(1, 1, 1) def attack(self) -> int: """ Returns the attack damage of the Hero """ return self.attributes.damage def take_damage(self, damage: int): self.attributes.health -= damage def take_buff(self, buff: HeroBuff): self.buff = buff.with_attributes(self.attributes) self.buff.apply() def pass_round(self): if self.buff: try: self.buff.pass_round() except HeroBuff.Expired: self.buff = None def is_alive(self): return self.attributes.health > 0 def take_item(self, item: Item): self.inventory.store_item(item) def equip_item(self, item: Item): if not self.inventory.has_item(item): raise Exception('Item is not present in inventory!') self.equipment.equip_item(item) 复制代码
现在,在将 Hero 对象分解为 HeroAttributes、HeroInventory、HeroEquipment 和 HeroBuff 对象之后,未来新增功能就更加容易、更具有封装性、具有更好的抽象,这份代码也就越来越清晰了。
下面是三种分解关系:
- 关联:在两个组成部分之间定义一个松弛的关系。两个组成部分不互相依赖,但是可以一起工作。例如 Hero 对象和 Zone 对象。
- 聚合:在整体和部分之间定义一个弱「包含」关系。这种关系比较弱,因为部分可以在没有整体的时候存在。例如 HeroInventory(英雄财产)和 Item(条目)。HeroInventory 可以有很多 Items,而且一个 Items 也可以属于任何 HeroInventory(例如交易条目)。
- 组成:一个强「包含」关系,其中整体和部分不能彼此分离。部分不能被共享,因为整体要依赖于这些特定的部分。例如 Hero(英雄)和 HeroAttributes(英雄属性)。
泛化
泛化可能是最重要的设计原则,即我们提取共享特征,并将它们结合到一起的过程。我们都知道函数和类的继承,这就是一种泛化。
做一个比较可能会将这个解释得更加清楚:尽管抽象通过隐藏非必需的细节减少了复杂性,但是泛化通过用一个单独构造体来替代多个执行类似功能的实体。
# Two methods which share common characteristics
def take_physical_damage(self, physical_damage):
print(f'Took {physical_damage} physical damage')
self._health -= physical_damage
def take_spell_damage(self, spell_damage):
print(f'Took {spell_damage} spell damage') self._health -= spell_damage # vs. # One generalized method def take_damage(self, damage, is_physical=True): damage_type = 'physical' if is_physical else 'spell' print(f'Took {damage} {damage_type} damage') self._health -= damage 复制代码
以上是函数示例,这种方法缺少泛化性能,而下面展示了具有泛化性能的案例。
class Entity:
def __init__(self):
raise Exception('Should not be initialized directly!')
def attack(self) -> int:
"""
Returns the attack damage of the Hero
"""
return self.attributes.damage def take_damage(self, damage: int): self.attributes.health -= damage def is_alive(self): return self.attributes.health > 0 class Hero(Entity): pass class NPC(Entity): pass 复制代码
在给出的例子中,我们将常用的 Hero 类和 NPC 类泛化为一个共同的父类 Entity,并通过继承简化子类的构建。
这里,我们通过将它们的共同功能移动到基本类中来减少复杂性,而不是让 NPC 类和 Hero 类将所有的功能都实现两次。
我们可能会过度使用继承,因此很多有经验的人都建议我们更偏向使用组合(Composition)而不是继承(stackoverflow.com/a/53354)。
继承常常被没有经验的程序员滥用,这可能是由于继承是他们首先掌握的 OOP 技术。
组合
组合就是把多个对象结合为一个更复杂对象的过程。这种方法会创建对象的示例,并且使用它们的功能,而不是直接继承它。
使用组合原则的对象就被称作组合对象(composite object)。这种组合对象在要比所有组成部分都简单,这是非常重要的一点。当把多个类结合成一个类的时候,我们希望把抽象的层次提高一些,让对象更加简单。
组合对象的 API 必须隐藏它的内部模块,以及内部模块之间的交互。就像一个机械时钟,它有三个展示时间的指针,以及一个设置时间的旋钮,但是它内部包含很多运动的独立部件。
正如我所说的,组合要优于继承,这意味着我们应该努力将共用功能移动到一个独立的对象中,然后其它类就使用这个对象的功能,而不是将它隐藏在所继承的基本类中。
让我们阐述一下过度使用继承功能的一个可能会发生的问题,现在我们仅仅向游戏中增加一个行动:
class Entity:
def __init__(self, x, y):
self.x = x
self.y = y
raise Exception('Should not be initialized directly!')
def attack(self) -> int:
"""
Returns the attack damage of the Hero
"""
return self.attributes.damage def take_damage(self, damage: int): self.attributes.health -= damage def is_alive(self): return self.attributes.health > 0 def move_left(self): self.x -= 1 def move_right(self): self.x += 1 class Hero(Entity): pass class NPC(Entity): pass 复制代码
正如我们所学到的,我们将 move_right 和 move_left 移动到 Entity 类中,而不是直接复制代码。
好了,如果我们想在游戏中引入坐骑呢?坐骑也应该需要左右移动,但是它没有攻击的能力,甚至没有生命值。
我们的解决方案可能是简单地将 move 逻辑移动到独立的 MoveableEntity 或者 MoveableObject 类中,这种类仅仅含有那项功能。
那么,如果我们想让坐骑具有生命值,但是无法攻击,那该怎么办呢?希望你可以看到类的层次结构是如何变得复杂的,即使我们的业务逻辑还是相当简单。
一个从某种程度来说比较好的方法是将动作逻辑抽象为 Movement 类(或者其他更好的名字),并且在可能需要的类里面把它实例化。这将会很好地封装函数,并使其在所有种类的对象中都可以重用,而不仅仅局限于实体类。
批判性思考
尽管这些设计原则是在数十年经验中形成的,但盲目地将这些原则应用到代码之前进行批判性思考是很重要的。
任何事情都是过犹不及!有时候这些原则可以走得很远,但是实际上有时会变成一些很难使用的东西。
作为一个工程师,我们需要根据独特的情境去批判地评价最好的方法,而不是盲目地遵从并应用任意的原则。
关注点的内聚、耦合和分离
内聚(Cohesion)
内聚代表的是模块内部责任的分明,或者是模块的复杂度。
如果我们的类只执行一个任务,而没有其它明确的目标,那么这个类就有着高度内聚性。另一方面,如果从某种程度而言它在做的事情并不清楚,或者具有多于一个的目标,那么它的内聚性就非常低。
我们希望代码具有较高的内聚性,如果发现它们有非常多的目标,或许我们应该将它们分割出来。
耦合
耦合获取的是连接不同类的复杂度。我们希望类与其它的类具有尽可能少、尽可能简单的联系,所以我们就可以在未来的事件中交换它们(例如改变网络框架)。
在很多编程语言中,这都是通过大量使用接口来实现的,它们抽象出处理特定逻辑的类,然后表征为一种适配层,每个类都可以嵌入其中。
分离关注点
分离关注点(SoC)是这样一种思想:软件系统必须被分割为功能上互不重叠的部分。或者说关注点必须分布在不同的地方,其中关注点表示能够为一个问题提供解决方案。
网页就是一个很好的例子,它具有三个层(信息层、表示层和行为层),这三个层被分为三个不同的地方(分别是 HTML,CSS,以及 JS)。
如果重新回顾一下我们的 RPG 例子,你会发现它在最开始具有很多关注点(应用 buffs 来计算袭击伤害、处理资产、装备条目,以及管理属性)。我们通过分解将那些关注点分割成更多的内聚类,它们抽象并封装了它们的细节。我们的 Hero 类现在仅仅作为一个组合对象,它比之前更加简单。
结语
对小规模的代码应用这些原则可能看起来很复杂。但是事实上,对于未来想要开发和维护的任何一个软件项目而言,这些规则都是必须的。在刚开始写这种代码会有些成本,但是从长期来看,它会回报以几倍增长。
这些原则保证我们的系统更加:
- 可扩展:高内聚使得不用关心不相关的功能就可以更容易地实现新模块。
- 可维护:低耦合保证一个模块的改变通常不会影响其它模块。高内聚保证一个系统需求的改变只需要更改尽可能少的类。
- 可重用:高内聚保证一个模块的功能是完整的,也是被妥善定义的。低耦合使得模块尽可能少地依赖系统的其它部分,这使得模块在其它软件中的重用变得更加容易。
在本文中,我们首先介绍了一些高级对象的类别(实体对象、边界对象以及控制对象)。然后我们了解了一些构建对象时使用的关键原则,比如抽象、泛化、分解和封装等。最后,我们引入了两个软件质量指标(耦合和内聚),然后学习了使用这些原则能够带来的好处。