代码之家  ›  专栏  ›  技术社区  ›  Kasper Holdum

何时对行为进行子类划分而不是区分行为

  •  9
  • Kasper Holdum  · 技术社区  · 15 年前

    我很难决定什么时候应该进行子类化,而不只是添加一个表示类的不同模式的实例变量,然后让类的方法根据所选模式进行操作。

    例如,假设我有一个 基础车 班级。在我的项目中,我将处理三种不同类型的汽车。 赛车 ,请 公共汽车 家庭模式 . 每个都有自己的齿轮实现,如何转动和座椅设置。我应该将我的汽车分为三个不同的车型,还是应该创建一个类型变量,使齿轮、转向和座椅通用,以便它们根据所选车型的不同而发挥不同的作用?

    在我目前的情况下,我正在开发一个游戏,我逐渐意识到它开始变得有点凌乱,所以我就可能重构我当前的代码征求意见。基本上有不同的地图,每个地图可以是三种模式之一。根据哪种模式,地图被定义为会有不同的行为,地图将以不同的方式构建。在一种模式下,我可能需要向玩家提供租金,并在超时基础上生成生物,其中另一个玩家负责生成生物,而在另一个模式中,可能会有一些自动生成的生物,以及玩家生成的生物和玩家构建建筑。所以我想知道是否最好有一个基映射类,然后将其子类化为每个不同的模式,或者是否继续沿着我当前的路径添加差异化的行为,这取决于映射类型变量设置为什么。

    5 回复  |  直到 15 年前
        1
  •  7
  •   Kasper Holdum    15 年前

    全部归功于阿特玛武器公司 http://www.xtremevbtalk.com 回答 in this thread

    这两种情况的核心是我认为面向对象设计的基本规则:单一责任原则。有两种表达方式:

    "A class should have one, and only one, reason to change."
    "A class should have one, and only one, responsibility."
    

    SRP是一个不可能总是被满足的理想,遵循这一原则是 坚硬的 .我倾向于追求“一个班级应该尽可能少的承担责任”,我们的大脑非常善于说服我们,一个非常复杂的单一班级比几个非常简单的班级更不复杂。最近,我已经开始尽我最大的努力编写较小的类,而且我的代码中的错误数量也大大减少了。在解雇它之前,先试试几个项目。

    我首先建议,不要通过创建一个映射基类和三个子类来开始设计,而是从一个将每个映射的独特行为分隔为表示通用“映射行为”的二级类的设计开始。这个职位致力于证明这种方法的优越性。如果没有对您的代码相当熟悉的知识,我很难具体说明,但我将使用一个非常简单的地图概念:

    Public Class Map
        Public ReadOnly Property MapType As MapType
    
        Public Sub Load(mapType)
        Public Sub Start()
    End Class
    

    地图类型 指示地图表示的三种地图类型中的哪一种。当您想更改映射类型时,可以调用 Load() 对于您要使用的映射类型;这将执行需要执行的任何操作,以在加载映射后清除当前映射状态、重置背景等。 开始() 被称为。如果地图有“每Y秒生成怪物X”之类的行为, 开始() 负责配置这些行为。

    这就是你现在拥有的,你明智地认为这是个坏主意。既然我提到了SRP,我们来计算一下 地图 .

    • 它必须管理所有三种映射类型的状态信息。(3+职责*)
    • 加载() 必须了解如何清除所有三种映射类型的状态,以及如何设置所有三种映射类型的初始状态(6项职责)
    • Start() 必须知道如何处理每种映射类型。(3职责)

    **从技术上讲,每个变量都是一种责任,但我已经将其简化了。*

    对于最后的总计,如果添加第四个映射类型会发生什么?你必须增加 更多 状态变量(1+职责),更新 加载() 能够清除和初始化状态(2个职责),并更新 开始() 处理新的行为(1项责任)。所以:

    数量 Map 责任: 12 +

    新映射所需的更改数: 4 +

    还有其他问题。很可能,一些映射类型将具有类似的状态信息,因此您将在状态之间共享变量。这使得 加载() 将忘记设置或清除变量,因为您可能不记得一个映射使用 福禄 为了一个目的,另一个目的完全不同。

    测试这个也不容易。假设你想为这个场景编写一个测试“当我创建一个‘繁殖怪物’地图时,地图应该每5秒生成一个新的怪物。”很容易讨论你如何测试这个:创建地图,设置它的类型,启动它,等待超过5秒的时间,然后检查敌人的数量。但是,我们的界面目前没有“敌人计数”属性。我们可以添加它,但是如果这是唯一一个有敌人计数的地图呢?如果我们添加属性,我们将拥有一个在2/3的情况下无效的属性。还不太清楚我们在测试“产卵怪物”地图时没有读取测试代码,因为所有测试都将测试 地图 班级。

    你当然可以 地图 抽象基类, 开始() 必须重写,并为每种类型的映射派生一个新类型。现在,责任 加载() 在其他地方,因为对象不能用其他实例替换自身。您也可以为此创建工厂类:

    Class MapCreator
        Public Function GetMap(mapType) As Map
    End Class
    

    现在,我们的映射层次结构可能看起来像这样(为了简单起见,只定义了一个派生映射):

    Public MustInherit Class Map
        Public MustOverride Sub Start()
    End Class
    
    Public Class RentalMap
        Inherits Map
    
        Public Overrides Sub Start()
    End Class
    

    加载() 由于已经讨论过的原因,不再需要。 MapType 在地图上是多余的,因为您可以检查对象的类型以查看它是什么(除非您有几种类型的 RentalMap ,然后它再次变得有用。) 开始() 在每个派生类中都被重写,因此您已经将状态管理的职责转移到了单个类中。让我们再做一次SRP检查:

    地图基类 0职责

    映射派生类 -必须管理状态(1) -必须执行某些特定于类型的工作(1)

    总计:2项职责

    添加新地图 (同上)2职责

    每类责任总数:

    添加新映射类的成本:

    这样好多了。我们的测试场景呢?我们的状态比较好,但仍然不是很好。我们可以在派生类上放置“许多敌人”属性,因为每个类是独立的,如果需要特定的信息,我们可以强制转换到特定的映射类型。不过,如果你有 RentalMapSlow RentalMapFast ?您必须为这些类中的每一个重复您的测试,因为每个类都有不同的逻辑。所以,如果你有4个测试和12个不同的地图,你会写并稍微调整48个测试。我们如何解决这个问题?

    当我们创建派生类时,我们做了什么?我们确定了类中每次都在变化的部分,并将其下推到子类中。如果我们创建了一个单独的 MapBehavior 我们可以随意交换的类?让我们看看在一个派生行为中这可能是什么样子:

    Public Class Map
        Public ReadOnly Property Behavior As MapBehavior
    
        Public Sub SetBehavior(behavior)
        Public Sub Start()
    End Class
    
    Public MustInherit Class MapBehavior
        Public MustOverride Sub Start()
    End Class
    
    Public Class PlayerSpawnBehavior
        Public Property EnemiesPerSpawn As Integer
        Public Property MaximumNumberOfEnemies As Integer
        Public ReadOnly Property NumberOfEnemies As Integer
    
        Public Sub SpawnEnemy()
        Public Sub Start()
    End Class
    

    现在使用地图需要给它一个特定的 MAP行为 呼唤 开始() 委托给行为的 开始() .所有状态信息都在行为对象中,因此地图实际上不需要知道任何关于它的信息。但是,如果您想要一个特定的映射类型,那么创建一个行为然后创建一个映射似乎是不方便的,对吧?所以你得到了一些类:

    Public Class PlayerSpawnMap
        Public Sub New()
            MyBase.New(New PlayerSpawnBehavior())
        End Sub
    End Class
    

    就是这样,一行新类的代码。想要一个硬性玩家生成地图吗?

    Public Class HardPlayerSpawnMap
        Public Sub New()
            ' Base constructor must be first line so call a function that creates the behavior
            MyBase.New(CreateBehavior()) 
        End Sub
    
        Private Function CreateBehavior() As MapBehavior
            Dim myBehavior As New PlayerSpawnBehavior()
            myBehavior.EnemiesPerSpawn = 10
            myBehavior.MaximumNumberOfEnemies = 300
        End Function
    End Class
    

    那么,这与在派生类上具有属性有什么不同呢?从行为的角度来看,没有什么不同。从测试的角度来看,这是一个重大的突破。 PlayerSpawnBehavior 有自己的一套测试。但自从 HardPlayerSpawnMap PlayerSpawnMap 两使用 玩棋子的行为 如果我测试过 玩棋子的行为 我不必为使用该行为的映射编写任何与行为相关的测试!让我们比较一下测试场景。

    在“一个带有类型参数的类”的情况下,如果3个行为有3个难度级别,并且每个行为有10个测试,那么您将编写90个测试(不包括测试以查看是否从每个行为转到另一个行为)。在“派生类”场景中,您将拥有9个需要10个测试的类:90个测试。在“行为类”场景中,您将为每个行为编写10个测试:30个测试。

    责任统计如下: MAP有1个职责:跟踪一个行为。 行为有两个责任:维持状态和执行行动。

    每类责任总数:

    添加新映射类的成本: 0(重用行为)或2(新行为)

    因此,我的观点是,“行为类”场景并不比“派生类”场景更难编写,但是它可以显著降低测试的负担。我读过类似的技术,多年来一直把它们当作“太麻烦”而不予理睬,直到最近才意识到它们的价值。这就是为什么我写了近10000个字符来解释和证明它。

        2
  •  3
  •   Fernando    15 年前

    如果您的子类型是父类型的某种专门化,则应该对其进行子类化。换句话说,如果您只需要功能性,那么应该避免继承。作为 Liskov Substitution Principle 状态:“如果s是t的子类型,则程序中t类型的对象可以替换为s类型的对象,而不会更改该程序的任何期望属性。”

        3
  •  1
  •   RCIX    15 年前

    在您的例子中,我将使用混合方法(这可能称为组合,我不知道),其中映射模式变量实际上是一个单独的对象,它将所有相关数据/行为存储到映射模式中。这样,您可以拥有任意多的模式,而不必对map类做太多的实际操作。

    Gutofb7把它钉在了头上,当你想把某个东西子类化的时候。举一个更具体的例子:在你的汽车类中,在你的程序中的任何地方,你所处理的是哪种类型的汽车,这有关系吗?现在,如果您对map进行子类化,您需要编写多少处理特定子类的代码?

        4
  •  1
  •   Community CDub    7 年前

    在你与地图和产卵讨论的特定问题中,我认为这是一个你想要支持的案例。 composition over inheritance . 当你想到它时,它们并不是三种不同类型的地图。相反,它们是相同的地图,有三种不同的产卵策略。因此,如果可能的话,您应该使生成函数成为一个单独的类,并将生成类的实例作为映射的成员。如果您的地图“模式”中的所有其他差异在性质上都相似,那么您可能根本不必对地图进行子类划分,尽管可能需要对不同的组件进行子类划分(即,具有一个“生成策略”基类,并对由此生成的三种类型进行子类划分),或者至少为它们提供一个公共接口。

    如果您认为每种类型的映射在概念上是不同的,那么我建议使用子类化,因为这似乎满足了Liskov的替换原则。然而,这并不是说你应该完全放弃写作。对于每种类型的映射都具有但可能具有不同行为/实现的那些属性,您应该考虑让基类将它们作为组件。这样,如果需要,您仍然可以混合和匹配功能,同时使用继承来保持关注点的分离。

        5
  •  1
  •   Ryan Florence    15 年前

    我不在C中编程,但在RubyonRails、Xcode和MooTools(javascript oop框架)中,同样的问题可能会被问到。

    我不喜欢有一个方法,当某个特定的、永久的、属性是错误的时候,它永远不会被使用。就像如果它是大众的bug,某些齿轮将永远不会转动。那太愚蠢了。

    如果我找到类似的方法,我会尝试将所有可以在我的不同“cars”之间共享的东西抽象成父类,并将方法和属性用于每种car,然后用它们的特定方法定义子类。