選自Medium,機器之心編譯。程序員
面向對象的編程在實現想法乃至系統的過程當中都很是重要,咱們不管是使用 TensorFlow 仍是 PyTorch 來構建模型都或多或少須要使用類和方法。而採用類的方法來構建模型會令代碼很是具備可讀性和條理性,本文介紹了算法實現中使用類和方法來構建模型所須要注意的設計原則,它們可讓咱們的機器學習代碼更加美麗迷人。
大多數現代編程語言都支持而且鼓勵面向對象編程(OOP)。即便咱們最近彷佛看到了一些偏離,由於人們開始使用不太受 OOP 影響的編程語言(例如 Go, Rust, Elixir, Elm, Scala),可是大多數仍是具備面向對象的屬性。咱們在這裏歸納出的設計原則也適用於非 OOP 編程語言。 算法
爲了成功地寫出清晰的、高質量的、可維護而且可擴展的代碼,咱們須要以 Python 爲例瞭解在過去數十年裏被證實是有效的設計原則。 數據庫
由於咱們要圍繞對象來創建代碼,因此區分它們的不一樣責任和變化是有用的。通常來講,面向對象的編程有三種類型的對象。編程
這類對象一般對應着問題空間中的一些現實實體。好比咱們要創建一個角色扮演遊戲(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),這些屬性根據具體的規則都是可修改的。bash
控制對象(有時候也稱做管理對象)主要負責與其它對象的協調,這是一些管理並調用其它對象的對象。咱們上面的 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。咱們還能夠很容易地繼承這個類,並複寫一些功能來知足新的須要。架構
這些是處在系統邊緣的對象。任何一個從其它系統獲取輸入或者給其它系統產生輸出的對象均可以被歸類爲邊界對象,不管那個系統是用戶,互聯網或者是數據庫。 app
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
複製代碼
這些邊界對象負責向系統內部或者外部傳遞信息。例如對要接收的用戶指令,咱們須要一個邊界對象來將鍵盤輸入(好比一個空格鍵)轉換爲一個可識別的域事件(例如角色的跳躍)。框架
價值對象表明的是域(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)
複製代碼
它們能夠歸類爲實體對象的子類別。
設計原則是軟件設計中的規則,過去這些年裏已經證實它們是有價值的。嚴格地遵循這些原則有助於軟件達到一流的質量。
抽象就是將一個概念在必定的語境中簡化爲原始本質的一種思想。它容許咱們拆解一個概念來更好的理解它。
上面的遊戲案例闡述了抽象,讓咱們來看一下 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 對象以後,將來新增功能就更加容易、更具備封裝性、具備更好的抽象,這份代碼也就愈來愈清晰了。
下面是三種分解關係:
泛化多是最重要的設計原則,即咱們提取共享特徵,並將它們結合到一塊兒的過程。咱們都知道函數和類的繼承,這就是一種泛化。
作一個比較可能會將這個解釋得更加清楚:儘管抽象經過隱藏非必需的細節減小了複雜性,可是泛化經過用一個單獨構造體來替代多個執行相似功能的實體。
# 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 類(或者其餘更好的名字),而且在可能須要的類裏面把它實例化。這將會很好地封裝函數,並使其在全部種類的對象中均可以重用,而不只僅侷限於實體類。
儘管這些設計原則是在數十年經驗中造成的,但盲目地將這些原則應用到代碼以前進行批判性思考是很重要的。
任何事情都是過猶不及!有時候這些原則能夠走得很遠,可是實際上有時會變成一些很難使用的東西。
做爲一個工程師,咱們須要根據獨特的情境去批判地評價最好的方法,而不是盲目地聽從並應用任意的原則。
內聚表明的是模塊內部責任的分明,或者是模塊的複雜度。
若是咱們的類只執行一個任務,而沒有其它明確的目標,那麼這個類就有着高度內聚性。另外一方面,若是從某種程度而言它在作的事情並不清楚,或者具備多於一個的目標,那麼它的內聚性就很是低。
咱們但願代碼具備較高的內聚性,若是發現它們有很是多的目標,或許咱們應該將它們分割出來。
耦合獲取的是鏈接不一樣類的複雜度。咱們但願類與其它的類具備儘量少、儘量簡單的聯繫,因此咱們就能夠在將來的事件中交換它們(例如改變網絡框架)。
在不少編程語言中,這都是經過大量使用接口來實現的,它們抽象出處理特定邏輯的類,而後表徵爲一種適配層,每一個類均可以嵌入其中。
分離關注點(SoC)是這樣一種思想:軟件系統必須被分割爲功能上互不重疊的部分。或者說關注點必須分佈在不一樣的地方,其中關注點表示可以爲一個問題提供解決方案。
網頁就是一個很好的例子,它具備三個層(信息層、表示層和行爲層),這三個層被分爲三個不一樣的地方(分別是 HTML,CSS,以及 JS)。
若是從新回顧一下咱們的 RPG 例子,你會發現它在最開始具備不少關注點(應用 buffs 來計算襲擊傷害、處理資產、裝備條目,以及管理屬性)。咱們經過分解將那些關注點分割成更多的內聚類,它們抽象並封裝了它們的細節。咱們的 Hero 類如今僅僅做爲一個組合對象,它比以前更加簡單。
對小規模的代碼應用這些原則可能看起來很複雜。可是事實上,對於將來想要開發和維護的任何一個軟件項目而言,這些規則都是必須的。在剛開始寫這種代碼會有些成本,可是從長期來看,它會回報以幾倍增加。
這些原則保證咱們的系統更加:
在本文中,咱們首先介紹了一些高級對象的類別(實體對象、邊界對象以及控制對象)。而後咱們瞭解了一些構建對象時使用的關鍵原則,好比抽象、泛化、分解和封裝等。最後,咱們引入了兩個軟件質量指標(耦合和內聚),而後學習了使用這些原則可以帶來的好處。
我但願這篇文章提供了一些關於設計原則的概覽,若是咱們但願本身可以在這個領域得到更多的進步,咱們還須要瞭解更多具體的操做。