选自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!'