如何寫一手漂亮的模型:面向對象編程的設計原則綜述
來自專欄 機器之心
選自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(fThe 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 users 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): passclass UserKeyboardInput(UserInput): passclass 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 fMoney 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))# => Trueprint(Money(1, 1, 1) > Money(1, 2, 1))# => Falseprint(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 deepcopyclass 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.2class 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._equipmentclass 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 -= strengthclass 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 == 0class 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 characteristicsdef take_physical_damage(self, physical_damage): print(fTook {physical_damage} physical damage) self._health -= physical_damagedef take_spell_damage(self, spell_damage): print(fTook {spell_damage} spell damage) self._health -= spell_damage# vs.# One generalized methoddef take_damage(self, damage, is_physical=True): damage_type = physical if is_physical else spell print(fTook {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 > 0class Hero(Entity): passclass NPC(Entity): pass
在給出的例子中,我們將常用的 Hero 類和 NPC 類泛化為一個共同的父類 Entity,並通過繼承簡化子類的構建。
這裡,我們通過將它們的共同功能移動到基本類中來減少複雜性,而不是讓 NPC 類和 Hero 類將所有的功能都實現兩次。
我們可能會過度使用繼承,因此很多有經驗的人都建議我們更偏向使用組合(Composition)而不是繼承(https://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 += 1class Hero(Entity): passclass NPC(Entity): pass
正如我們所學到的,我們將 move_right 和 move_left 移動到 Entity 類中,而不是直接複製代碼。
好了,如果我們想在遊戲中引入坐騎呢?坐騎也應該需要左右移動,但是它沒有攻擊的能力,甚至沒有生命值。我們的解決方案可能是簡單地將 move 邏輯移動到獨立的 MoveableEntity 或者 MoveableObject 類中,這種類僅僅含有那項功能。
那麼,如果我們想讓坐騎具有生命值,但是無法攻擊,那該怎麼辦呢?希望你可以看到類的層次結構是如何變得複雜的,即使我們的業務邏輯還是相當簡單。
一個從某種程度來說比較好的方法是將動作邏輯抽象為 Movement 類(或者其他更好的名字),並且在可能需要的類裡面把它實例化。這將會很好地封裝函數,並使其在所有種類的對象中都可以重用,而不僅僅局限於實體類。
批判性思考
儘管這些設計原則是在數十年經驗中形成的,但盲目地將這些原則應用到代碼之前進行批判性思考是很重要的。
任何事情都是過猶不及!有時候這些原則可以走得很遠,但是實際上有時會變成一些很難使用的東西。
作為一個工程師,我們需要根據獨特的情境去批判地評價最好的方法,而不是盲目地遵從並應用任意的原則。
關注點的內聚、耦合和分離
內聚(Cohesion)
內聚代表的是模塊內部責任的分明,或者是模塊的複雜度。
如果我們的類只執行一個任務,而沒有其它明確的目標,那麼這個類就有著高度內聚性。另一方面,如果從某種程度而言它在做的事情並不清楚,或者具有多於一個的目標,那麼它的內聚性就非常低。
我們希望代碼具有較高的內聚性,如果發現它們有非常多的目標,或許我們應該將它們分割出來。
耦合
耦合獲取的是連接不同類的複雜度。我們希望類與其它的類具有儘可能少、儘可能簡單的聯繫,所以我們就可以在未來的事件中交換它們(例如改變網路框架)。
在很多編程語言中,這都是通過大量使用介面來實現的,它們抽象出處理特定邏輯的類,然後表徵為一種適配層,每個類都可以嵌入其中。
分離關注點
分離關注點(SoC)是這樣一種思想:軟體系統必須被分割為功能上互不重疊的部分。或者說關注點必須分布在不同的地方,其中關注點表示能夠為一個問題提供解決方案。
網頁就是一個很好的例子,它具有三個層(信息層、表示層和行為層),這三個層被分為三個不同的地方(分別是 HTML,CSS,以及 JS)。
如果重新回顧一下我們的 RPG 例子,你會發現它在最開始具有很多關注點(應用 buffs 來計算襲擊傷害、處理資產、裝備條目,以及管理屬性)。我們通過分解將那些關注點分割成更多的內聚類,它們抽象並封裝了它們的細節。我們的 Hero 類現在僅僅作為一個組合對象,它比之前更加簡單。
結語
對小規模的代碼應用這些原則可能看起來很複雜。但是事實上,對於未來想要開發和維護的任何一個軟體項目而言,這些規則都是必須的。在剛開始寫這種代碼會有些成本,但是從長期來看,它會回報以幾倍增長。
這些原則保證我們的系統更加:
- 可擴展:高內聚使得不用關心不相關的功能就可以更容易地實現新模塊。
- 可維護:低耦合保證一個模塊的改變通常不會影響其它模塊。高內聚保證一個系統需求的改變只需要更改儘可能少的類。
- 可重用:高內聚保證一個模塊的功能是完整的,也是被妥善定義的。低耦合使得模塊儘可能少地依賴系統的其它部分,這使得模塊在其它軟體中的重用變得更加容易。
在本文中,我們首先介紹了一些高級對象的類別(實體對象、邊界對象以及控制對象)。然後我們了解了一些構建對象時使用的關鍵原則,比如抽象、泛化、分解和封裝等。最後,我們引入了兩個軟體質量指標(耦合和內聚),然後學習了使用這些原則能夠帶來的好處。
我希望這篇文章提供了一些關於設計原則的概覽,如果我們希望自己能夠在這個領域獲得更多的進步,我們還需要了解更多具體的操作。
原文地址:https://medium.freecodecamp.org/a-short-overview-of-object-oriented-software-design-c7aa0a622c83
推薦閱讀:
※強化學習筆記3—有限馬爾科夫決策過程
※【翻譯】德雷夫斯:為什麼海德格爾式的人工智慧失敗了,如何修復它則需要讓它變得更海德格爾式
※我理解的人工智慧
※你定好新年工作計划了嗎?再談談AI對我們工作的影響
※人工智慧的演算法黑箱與數據正義