如何写一手漂亮的模型:面向对象编程的设计原则综述

选自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),这些属性根据具体的规则都是可修改的。bash

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)

这些是处在系统边缘的对象。任何一个从其它系统获取输入或者给其它系统产生输出的对象均可以被归类为边界对象,不管那个系统是用户,互联网或者是数据库。 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
复制代码

这些边界对象负责向系统内部或者外部传递信息。例如对要接收的用户指令,咱们须要一个边界对象来将键盘输入(好比一个空格键)转换为一个可识别的域事件(例如角色的跳跃)。框架

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 类如今仅仅做为一个组合对象,它比以前更加简单。

结语

对小规模的代码应用这些原则可能看起来很复杂。可是事实上,对于将来想要开发和维护的任何一个软件项目而言,这些规则都是必须的。在刚开始写这种代码会有些成本,可是从长期来看,它会回报以几倍增加。

这些原则保证咱们的系统更加:

  • 可扩展:高内聚使得不用关心不相关的功能就能够更容易地实现新模块。
  • 可维护:低耦合保证一个模块的改变一般不会影响其它模块。高内聚保证一个系统需求的改变只须要更改尽量少的类。
  • 可重用:高内聚保证一个模块的功能是完整的,也是被妥善定义的。低耦合使得模块尽量少地依赖系统的其它部分,这使得模块在其它软件中的重用变得更加容易。

在本文中,咱们首先介绍了一些高级对象的类别(实体对象、边界对象以及控制对象)。而后咱们了解了一些构建对象时使用的关键原则,好比抽象、泛化、分解和封装等。最后,咱们引入了两个软件质量指标(耦合和内聚),而后学习了使用这些原则可以带来的好处。

我但愿这篇文章提供了一些关于设计原则的概览,若是咱们但愿本身可以在这个领域得到更多的进步,咱们还须要了解更多具体的操做。

原文地址:medium.freecodecamp.org/a-short-ove…

相关文章
相关标签/搜索