如何寫一手漂亮的模型:面向?qū)ο缶幊痰脑O(shè)計原則綜述
面向?qū)ο蟮木幊淘趯崿F(xiàn)想法乃至系統(tǒng)的過程中都非常重要,我們不論是使用 TensorFlow 還是 PyTorch 來構(gòu)建模型都或多或少需要使用類和方法。而采用類的方法來構(gòu)建模型會令代碼非常具有可讀性和條理性,本文介紹了算法實現(xiàn)中使用類和方法來構(gòu)建模型所需要注意的設(shè)計原則,它們可以讓我們的機器學習代碼更加美麗迷人。
大多數(shù)現(xiàn)代編程語言都支持并且鼓勵面向?qū)ο缶幊?OOP)。即使我們最近似乎看到了一些偏離,因為人們開始使用不太受 OOP 影響的編程語言(例如 Go, Rust, Elixir, Elm, Scala),但是大多數(shù)還是具有面向?qū)ο蟮膶傩?。我們在這里概括出的設(shè)計原則也適用于非 OOP 編程語言。
為了成功地寫出清晰的、高質(zhì)量的、可維護并且可擴展的代碼,我們需要以 Python 為例了解在過去數(shù)十年里被證明是有效的設(shè)計原則。
一、對象類型
因為我們要圍繞對象來建立代碼,所以區(qū)分它們的不同責任和變化是有用的。一般來說,面向?qū)ο蟮木幊逃腥N類型的對象。
1. 實體對象
這類對象通常對應著問題空間中的一些現(xiàn)實實體。比如我們要建立一個角色扮演游戲(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
這類對象通常包含關(guān)于它們自身的屬性(例如 health 或 mana),這些屬性根據(jù)具體的規(guī)則都是可修改的。
2. 控制對象(Control Object)
控制對象(有時候也稱作管理對象)主要負責與其它對象的協(xié)調(diào),這是一些管理并調(diào)用其它對象的對象。我們上面的 RPG 案例中有一個很棒的例子,F(xiàn)ight 類控制兩個英雄,并讓它們對戰(zhàn)。
- 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
在這種類中,為對戰(zhàn)封裝編程邏輯可以給我們提供多個好處:其中之一就是動作的可擴展性。我們可以很容易地將參與戰(zhàn)斗的英雄傳遞給非玩家角色(NPC),這樣它們就能利用相同的 API。我們還可以很容易地繼承這個類,并復寫一些功能來滿足新的需要。
3. 邊界對象(Boundary Object)
這些是處在系統(tǒng)邊緣的對象。任何一個從其它系統(tǒng)獲取輸入或者給其它系統(tǒng)產(chǎn)生輸出的對象都可以被歸類為邊界對象,無論那個系統(tǒng)是用戶,互聯(lián)網(wǎng)或者是數(shù)據(jù)庫。
- 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
這些邊界對象負責向系統(tǒng)內(nèi)部或者外部傳遞信息。例如對要接收的用戶指令,我們需要一個邊界對象來將鍵盤輸入(比如一個空格鍵)轉(zhuǎn)換為一個可識別的域事件(例如角色的跳躍)。
4. Bonus:值對象(Value Object)
價值對象代表的是域(domain)中的一個簡單值。它們無法改變,不恒一。
如果將它們結(jié)合在我們的游戲中,Money 類或者 Damage 類就表示這種對象。上述的對象讓我們?nèi)菀椎貐^(qū)分、尋找和調(diào)試相關(guān)功能,然而僅使用基礎(chǔ)的整形數(shù)組或者整數(shù)卻無法實現(xiàn)這些功能。
- 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)
它們可以歸類為實體對象的子類別。
二、關(guān)鍵設(shè)計原則
設(shè)計原則是軟件設(shè)計中的規(guī)則,過去這些年里已經(jīng)證明它們是有價值的。嚴格地遵循這些原則有助于軟件達到一流的質(zhì)量。
1. 抽象(Abstraction)
抽象就是將一個概念在一定的語境中簡化為原始本質(zhì)的一種思想。它允許我們拆解一個概念來更好的理解它。
上面的游戲案例闡述了抽象,讓我們來看一下 Fight 類是如何構(gòu)建的。我們以盡可能簡單的方式使用它,即在實例化的過程中給它兩個英雄作為參數(shù),然后調(diào)用 fight() 方法。不多也不少,就這些。
代碼中的抽象過程應該遵循最少意外(POLA)的原則,抽象不應該用不必要和不相關(guān)的行為/屬性。換句話說,它應該是直觀的。
注意,我們的 Hero#take_damage() 函數(shù)不會做一些異常的事情,例如在還沒死亡的時候刪除角色。但是如果他的生命值降到零以下,我們可以期望它來殺死我們的角色。
2. 封裝
封裝可以被認為是將某些東西放在一個類以內(nèi),并限制了它向外部展現(xiàn)的信息。在軟件中,限制對內(nèi)部對象和屬性的訪問有助于保證數(shù)據(jù)的完整性。
將內(nèi)部編程邏輯封裝成黑盒子,我們的類將更容易管理,因為我們知道哪部分可以被其它系統(tǒng)使用,哪些不行。這意味著我們在保留公共部分并且保證不破壞任何東西的同時能夠重用內(nèi)部邏輯。此外,我們從外部使用封裝功能變得更加簡單,因為需要考慮的事情也更少。
在大多數(shù)編程語言中,封裝都是通過所謂的 Access modifiers(訪問控制修飾符)來完成的(例如 private,protected 等等)。Python 并不是這方面的最佳例子,因為它不能在運行時構(gòu)建這種顯式修飾符,但是我們使用約定來解決這個問題。變量和函數(shù)前面的_前綴就意味著它們是私有的。
舉個例子,試想將我們的 Fight#_run_attack 方法修改為返回一個布爾變量,這意味著戰(zhàn)斗結(jié)束而不是發(fā)生了意外。我們將會知道,我們唯一可能破壞的代碼就是 Fight 類的內(nèi)部,因為我們是把這個函數(shù)設(shè)置為私有的。
請記住,代碼更多的是被修改而不是重寫。能夠盡可能清晰、較小影響的方式修改代碼對開發(fā)的靈活性很重要。
3. 分解
分解就是把一個對象分割為多個更小的獨立部分,這些獨立的部分更易于理解、維護和編程。
試想我們現(xiàn)在希望 Hero 類能結(jié)合更多的 RPG 特征,例如 buffs,資產(chǎn),裝備,角色屬性。
- 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
- # 缺乏分解的案例
我們可能會說這份代碼已經(jīng)開始變得相當混亂了。
例如,我們的耐力分數(shù)為 5 個生命值,如果將來要修改為 6 個生命值,我們就要在很多地方修改這個實現(xiàn)。
解決方案就是將 Hero 對象分解為多個更小的對象,每個小對象可承擔一些功能。下面展示了一個邏輯比較清晰的架構(gòu):
- 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)
現(xiàn)在,在將 Hero 對象分解為 HeroAttributes、HeroInventory、HeroEquipment 和 HeroBuff 對象之后,未來新增功能就更加容易、更具有封裝性、具有更好的抽象,這份代碼也就越來越清晰了。
下面是三種分解關(guān)系:
- 關(guān)聯(lián):在兩個組成部分之間定義一個松弛的關(guān)系。兩個組成部分不互相依賴,但是可以一起工作。例如 Hero 對象和 Zone 對象。
- 聚合:在整體和部分之間定義一個弱「包含」關(guān)系。這種關(guān)系比較弱,因為部分可以在沒有整體的時候存在。例如 HeroInventory(英雄財產(chǎn))和 Item(條目)。HeroInventory 可以有很多 Items,而且一個 Items 也可以屬于任何 HeroInventory(例如交易條目)。
- 組成:一個強「包含」關(guān)系,其中整體和部分不能彼此分離。部分不能被共享,因為整體要依賴于這些特定的部分。例如 Hero(英雄)和 HeroAttributes(英雄屬性)。
4. 泛化
泛化可能是最重要的設(shè)計原則,即我們提取共享特征,并將它們結(jié)合到一起的過程。我們都知道函數(shù)和類的繼承,這就是一種泛化。
做一個比較可能會將這個解釋得更加清楚:盡管抽象通過隱藏非必需的細節(jié)減少了復雜性,但是泛化通過用一個單獨構(gòu)造體來替代多個執(zhí)行類似功能的實體。
- # 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
以上是函數(shù)示例,這種方法缺少泛化性能,而下面展示了具有泛化性能的案例。
- 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
這里,我們通過將它們的共同功能移動到基本類中來減少復雜性,而不是讓 NPC 類和 Hero 類將所有的功能都實現(xiàn)兩次。
我們可能會過度使用繼承,因此很多有經(jīng)驗的人都建議我們更偏向使用組合(Composition)而不是繼承(https://stackoverflow.com/a/53354)。
繼承常常被沒有經(jīng)驗的程序員濫用,這可能是由于繼承是他們首先掌握的 OOP 技術(shù)。
5. 組合
組合就是把多個對象結(jié)合為一個更復雜對象的過程。這種方法會創(chuàng)建對象的示例,并且使用它們的功能,而不是直接繼承它。
使用組合原則的對象就被稱作組合對象(composite object)。這種組合對象在要比所有組成部分都簡單,這是非常重要的一點。當把多個類結(jié)合成一個類的時候,我們希望把抽象的層次提高一些,讓對象更加簡單。
組合對象的 API 必須隱藏它的內(nèi)部模塊,以及內(nèi)部模塊之間的交互。就像一個機械時鐘,它有三個展示時間的指針,以及一個設(shè)置時間的旋鈕,但是它內(nèi)部包含很多運動的獨立部件。
正如我所說的,組合要優(yōu)于繼承,這意味著我們應該努力將共用功能移動到一個獨立的對象中,然后其它類就使用這個對象的功能,而不是將它隱藏在所繼承的基本類中。
讓我們闡述一下過度使用繼承功能的一個可能會發(fā)生的問題,現(xiàn)在我們僅僅向游戲中增加一個行動:
- 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 邏輯移動到獨立的 MoveableEntity 或者 MoveableObject 類中,這種類僅僅含有那項功能。
那么,如果我們想讓坐騎具有生命值,但是無法攻擊,那該怎么辦呢?希望你可以看到類的層次結(jié)構(gòu)是如何變得復雜的,即使我們的業(yè)務邏輯還是相當簡單。
一個從某種程度來說比較好的方法是將動作邏輯抽象為 Movement 類(或者其他更好的名字),并且在可能需要的類里面把它實例化。這將會很好地封裝函數(shù),并使其在所有種類的對象中都可以重用,而不僅僅局限于實體類。
6. 批判性思考
盡管這些設(shè)計原則是在數(shù)十年經(jīng)驗中形成的,但盲目地將這些原則應用到代碼之前進行批判性思考是很重要的。
任何事情都是過猶不及!有時候這些原則可以走得很遠,但是實際上有時會變成一些很難使用的東西。
作為一個工程師,我們需要根據(jù)獨特的情境去批判地評價最好的方法,而不是盲目地遵從并應用任意的原則。
三、關(guān)注點的內(nèi)聚、耦合和分離
1. 內(nèi)聚(Cohesion)
內(nèi)聚代表的是模塊內(nèi)部責任的分明,或者是模塊的復雜度。
如果我們的類只執(zhí)行一個任務,而沒有其它明確的目標,那么這個類就有著高度內(nèi)聚性。另一方面,如果從某種程度而言它在做的事情并不清楚,或者具有多于一個的目標,那么它的內(nèi)聚性就非常低。
我們希望代碼具有較高的內(nèi)聚性,如果發(fā)現(xiàn)它們有非常多的目標,或許我們應該將它們分割出來。
2. 耦合
耦合獲取的是連接不同類的復雜度。我們希望類與其它的類具有盡可能少、盡可能簡單的聯(lián)系,所以我們就可以在未來的事件中交換它們(例如改變網(wǎng)絡(luò)框架)。
在很多編程語言中,這都是通過大量使用接口來實現(xiàn)的,它們抽象出處理特定邏輯的類,然后表征為一種適配層,每個類都可以嵌入其中。
3. 分離關(guān)注點
分離關(guān)注點(SoC)是這樣一種思想:軟件系統(tǒng)必須被分割為功能上互不重疊的部分。或者說關(guān)注點必須分布在不同的地方,其中關(guān)注點表示能夠為一個問題提供解決方案。
網(wǎng)頁就是一個很好的例子,它具有三個層(信息層、表示層和行為層),這三個層被分為三個不同的地方(分別是 HTML,CSS,以及 JS)。
如果重新回顧一下我們的 RPG 例子,你會發(fā)現(xiàn)它在最開始具有很多關(guān)注點(應用 buffs 來計算襲擊傷害、處理資產(chǎn)、裝備條目,以及管理屬性)。我們通過分解將那些關(guān)注點分割成更多的內(nèi)聚類,它們抽象并封裝了它們的細節(jié)。我們的 Hero 類現(xiàn)在僅僅作為一個組合對象,它比之前更加簡單。
四、結(jié)語
對小規(guī)模的代碼應用這些原則可能看起來很復雜。但是事實上,對于未來想要開發(fā)和維護的任何一個軟件項目而言,這些規(guī)則都是必須的。在剛開始寫這種代碼會有些成本,但是從長期來看,它會回報以幾倍增長。
這些原則保證我們的系統(tǒng)更加:
- 可擴展:高內(nèi)聚使得不用關(guān)心不相關(guān)的功能就可以更容易地實現(xiàn)新模塊。
- 可維護:低耦合保證一個模塊的改變通常不會影響其它模塊。高內(nèi)聚保證一個系統(tǒng)需求的改變只需要更改盡可能少的類。
- 可重用:高內(nèi)聚保證一個模塊的功能是完整的,也是被妥善定義的。低耦合使得模塊盡可能少地依賴系統(tǒng)的其它部分,這使得模塊在其它軟件中的重用變得更加容易。
在本文中,我們首先介紹了一些高級對象的類別(實體對象、邊界對象以及控制對象)。然后我們了解了一些構(gòu)建對象時使用的關(guān)鍵原則,比如抽象、泛化、分解和封裝等。最后,我們引入了兩個軟件質(zhì)量指標(耦合和內(nèi)聚),然后學習了使用這些原則能夠帶來的好處。
我希望這篇文章提供了一些關(guān)于設(shè)計原則的概覽,如果我們希望自己能夠在這個領(lǐng)域獲得更多的進步,我們還需要了解更多具體的操作。
原文地址:
https://medium.freecodecamp.org/a-short-overview-of-object-oriented-software-design-c7aa0a622c83
【本文是51CTO專欄機構(gòu)“機器之心”的原創(chuàng)譯文,微信公眾號“機器之心( id: almosthuman2014)”】