如何构建基本的类层次结构?

9
我知道如何编写代码和使用简单类,甚至知道继承是什么以及如何使用它。然而,关于如何实际设计您的类层次结构,或者如何设计一个简单的类,可供参考的指南非常有限。此外,在什么情况下以及为什么我要继承(或使用)一个类也不清楚。
所以我不是在问“如何”,而是在问“何时”和“为什么”。示例代码总是学习的好方法,所以我会很感激。此外,强调设计过程,而不是仅仅给出一个关于何时和为什么的句子。
我主要使用C++,C#和Python进行编程,但我可能会理解大多数语言中的简单示例。
如果有任何术语混淆,请随意编辑我的问题。我不是母语人士,也不确定所有单词的含义。

我认为一个好的经验法则是尽可能保持类层次结构扁平化。不要过度使用继承。这篇文章总结了一些常见问题。 - Alexandre C.
5个回答

11
我将以C++作为示例语言,因为它非常依赖继承和类。 以下是一个简单的指南,介绍如何为简单的操作系统(例如Windows)构建控件。 控件包括窗口上的简单对象,例如按钮、滑块、文本框等。

构建一个基本类。

这部分指南适用于(几乎)任何类。 记住,计划周全就是成功的一半。 我们正在开发哪种类型的类? 它有哪些属性和需要什么方法? 这些都是我们需要考虑的主要问题。

我们在这里正在处理操作系统控件,因此让我们从一个简单的类开始,比如说Button。现在,我们的按钮有哪些属性呢?显然,它需要在窗口上的一个位置。此外,我们不希望每个按钮都是完全相同的大小,所以size是另一个属性。按钮还“需要”一个label(绘制在按钮上的文本)。这就是你对每个类所做的事情,你设计它,然后编写它。现在我知道我需要哪些属性,所以让我们来构建这个类。

class Button
{
    private:
        Point m_position;
        Size m_size;
        std::string m_label;
}

注意,为了缩短代码,我省略了所有的getter、setter和其他方法,但你也必须包含它们。我还期望我们有PointSize类,通常我们需要自己构建它们。

进入下一个类。

现在我们已经完成了一个类(Button),我们可以转到下一个类。 让我们选择 Slider,这是一个条形控件,例如帮助您上下滚动网页。

让我们像处理按钮一样开始,我们的滑块类需要什么? 它有窗口上的位置(position)和滑块的size。此外,它还有最小值和最大值(最小值表示滚动条设置为滑块顶部,最大值表示滚动条设置为底部)。我们还需要当前值,即滚动条目前所在的位置。现在这就足够了,我们可以构建我们的类:

class Slider
{
    private:
        Point m_position;
        Size m_size;
        int m_minValue;
        int m_maxValue;
        int m_currentValue;
}

创建一个基类。

现在我们有了两个类,第一件事情是我们注意到我们刚刚在两个类中定义了Point m_position;Size m_size;属性。这意味着我们有两个具有共同元素的类,而我们刚刚重复编写了相同的代码,如果我们只能编写一次代码并告诉我们的两个类使用该代码而不是重写它,那将是很棒的,对吧?好吧,我们可以做到。

如果我们有两个具有共同属性的相似类,比如ButtonSlider,那么“通常”(当然也有例外,但初学者不必担心)建议创建一个基类。它们都是我们操作系统上的控件,具有sizeposition。从这里我们得到一个新类,叫做Control

class Control
{
    private:
        Point m_position;
        Size m_size;
}

从共同基类继承相似的类。

现在我们有了包含每个控制常见项的Control类,我们可以让我们的ButtonSlider从它继承。这将节省我们时间、计算机内存并最终节约时间。以下是我们的新类:

class Control
{
    private:
        Point m_position;
        Size m_size;
}

class Button : public Control
{
    private:
        std::string m_label
}

class Slider : public Control
{
    private:
        int m_minValue;
        int m_maxValue;
        int m_currentValue;
}

现在有些人可能会说,写两次Point m_position; Size m_size;比写两次: public Control和创建Control类要容易得多。 在某些情况下这可能是正确的,但仍然建议不要重复编写相同的代码,尤其是在创建类时。

此外,谁知道我们最终会发现多少个共同的属性。后来我们可能会意识到需要将Control* m_parent成员添加到Control类中,该成员指向包含我们控件的窗口(或面板等)。

另一件事是,如果我们后来意识到除了SliderButton之外,我们还需要TextBox,我们只需通过class TextBox : public Control { ... }创建一个文本框控件,并仅编写特定于文本框的成员变量,而不是在每个类中反复编写大小、位置和父级。


最后的想法。

基本上,当您有两个具有共同属性或方法的类时,应该创建一个基类。 这是基本规则,但您可以使用自己的大脑,因为可能会有一些例外。

我自己也不是专业的编码人员,但我正在学习,并且我已经像我的教育者教给我的那样教给了你。我希望您(或至少有人)会发现这个答案有用。 即使有些人说Python和其他鸭子类型语言甚至不需要使用继承,他们是错误的。 使用继承将在更大的项目中节省您很多时间和金钱,最终您将感谢自己创建基类。 您的项目的可重用性和管理将变得容易亿万倍。


1
我喜欢所有的答案并学到了很多,但这个(在我看来)比其他答案更好。我越想,做越多的研究,这个答案就越有道理,也教给我最多。我真的很喜欢你逐步讲解的方式。感谢你的回答,也感谢其他人的回答! :) 如果可以的话,我会接受大部分的答案。 - Skamah One

1

当你有两个类包含单个类的属性,或者其中一个类依赖于另一个类时,你需要使用继承。例如:

class animal:
    #something
class dog(animal):
    #something
class cat(animal):
    #something

这里有两个类,狗和猫,它们拥有类动物的属性。在这里,继承发挥了作用。

class parent:
    #something
class child(parent):
    #something

在这里,父类和子类是两个类,其中子类依赖于父类,子类具有父类的属性以及自己独特的属性。因此,在这里使用了继承。

1
组合和聚合怎么样? - Captain Obvlious
@CaptainObvlious 那些是什么?为什么不留下你自己的答案,我不会接受第一个答案,只会接受最好的答案。 - Skamah One
组合和聚合指什么? - Aswin Murugesh
好的例子,但我有点担心短语“需要使用继承”。那些使用没有继承的语言的人该怎么办? - Drew Dormann
1
@DrewDormann 这个问题涉及到继承。那么没有继承的语言就与这个问题无关了,对吗? - Aswin Murugesh
@AswinMurugesh 没错。我建议在这个问题中,使用“需要”这个词是不正确的。 - Drew Dormann

1

这取决于编程语言。

例如,在Python中,通常不需要太多的继承,因为您可以将任何对象传递给任何函数,如果对象实现了正确的方法,一切都会很好。

class Dog:
    def __init__(self, name):
        self.name = name

    def sing(self):
        return self.name + " barks"

class Cat:
    def __init__(self, name):
        self.name = name

    def sing(self):
        return self.name + " meows"

在上面的代码中,DogCat是不相关的类,但您可以将任何一个实例传递给使用name并调用方法sing的函数。
在C++中,您将被迫添加一个基类(例如Animal),并将这两个类声明为派生类。
当然,在Python中也实现了继承,并且很有用,但在许多情况下,例如在C++或Java中需要它的情况下,由于“鸭子类型”,您可以避免使用它。
但是,如果您想要继承某些方法的实现(在本例中为构造函数),则还可以在Python中使用继承。
class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def sing(self):
        return self.name + " barks"

class Cat(Animal):
    def sing(self):
        return self.name + " meows"

继承的负面影响是,你的类会更加耦合,难以在其他未知的上下文中重用。
有人说,在面向对象编程(实际上是类导向编程)中,有时你只需要一根香蕉,但却得到了一个拿着香蕉和整个丛林的大猩猩。

你回答中的最后两段,我认为你并没有完全理解继承的实际好处。请给我一个例子,证明“继承的负面影响是你的类会更加耦合,更难以在其他你现在无法预见的上下文中重用。”这一说法是正确的。面向对象编程的设计初衷就是与此相反,当正确使用时,它正好是相反的。 - user2032433
如果您完全了解问题,那么您可以使用继承建立准确的深层次层次结构,甚至多重继承,通过共享公共部分来节省很多。然而,完美的问题描述很少是我们面临的,如果以后需要添加某些不符合方案的内容,因为未预料到,那么您就会遇到麻烦。另一个问题是,层次结构越复杂,重用部分而不将其全部带走就越困难(大猩猩问题)......如果您改用更简单的对象进行组合和委托,则重用更容易。 - 6502
如果您需要稍后添加某些内容,但无法添加,则显然您之前做错了什么。这不是面向对象编程的问题,而是您自己的问题。此外,您能否将某些内容添加到“动物”类中,而不能添加到“狗”和“猫”类中呢?因为我想不出动物会有什么,而狗没有的东西。此外,大猩猩问题根本不存在。如果您正确编写了类,就永远不会得到任何无用的东西。正如我所说,给我一个例子,一段代码。 - user2032433
@MarkusMeskanen:最明显的情况是当您将功能放置在抽象基类中时。如果您想要该功能,则需要使用派生类(大猩猩),可能会有其他不相关的依赖项(丛林),或者您将需要创建另一个派生类,仍然被原始设计强制执行。OOD从不代表“现实”,而只描述了好的ROI级别的特定视图。如果您过度使用继承,将更难以仅重用该设计的一部分。 - 6502
另外,你的第二条评论根本没有任何意义。不冒犯,但显然你并不完全理解继承的全部好处。它是基于对象的,事物是按对象分类的,而不是按功能分类的。如果你不喜欢它的工作方式,那就用 C,不使用类编写程序。现在让我用正确的继承树在 C++ 中编写相同的代码,看看谁能用更少的代码和更快(更轻)的程序。让我们向我们的程序添加50个新元素,看看哪一个更容易改变和/或重用现有的代码。C++ 能够战胜 C,这是有原因的,只是人们无法理解这个原因。 - user2032433
显示剩余2条评论

1
我会开始从wikipedia中的类定义开始:

在面向对象编程中,类是一种构造,用于创建其自身的实例 - 称为类实例、类对象、实例对象或简称对象。一个类定义了组成成员,使其实例具有状态和行为。数据字段成员(成员变量或实例变量)使类实例维护状态。其他类型的成员,特别是方法,使类实例具有行为。类定义了它们的实例的类型。

通常你会看到使用狗、动物、猫等例子。但让我们来看看一些实际的东西。
当你需要封装某些函数和方法时,最直接的情况是当它们合理地放在一起时。让我们想象一些简单的东西:HTTP请求。
创建HTTP请求需要什么?服务器、端口、协议、头部、URI等等...你可以把它们都放入字典中,例如:{'server': 'google.com'},但是如果你使用类来实现,你就明确表明需要这些属性一起使用,并且你将使用它们来完成这个特定的任务。
对于方法而言,你可以再次创建方法fetch(dict_of_settings),但是整个功能都绑定到HTTP类的属性上,没有这些属性就没有意义。
class HTTP:
    def __init__(self):
        self.server = ...
        self.port = ...
        ...

    def fetch(self):
        connect to self.server on port self.port
        ...

r1 = HTTP(...)
r2 = HTTP(...)
r1.port = ...
data = r1.fetch()

这不是很好看和易读吗?


抽象类/接口

这一点很快就能理解...假设你想在项目中实现依赖注入,以使你的应用程序独立于数据库引擎。

因此,你建议每个数据库连接器都应该实现一个接口(由抽象类表示),然后依赖于你的应用程序中的通用方法。比方说,你定义了DatabaseConnectorAbstract(在Python中不需要实际定义,但是在C++/C#中提出接口时需要)并定义了方法:

class DatabaseConnectorAbstract:
    def connect(): raise NotImplementedError(  )
    def fetch_articles_list(): raise NotImplementedError(  )
    ...

# And build mysql implementation
class DatabaseConnectorMysql(DatabaseConnectorAbstract):
   ...

# And finally use it in your application
class Application:
    def __init__(self,database_connector):
        if not isinstanceof(database_connector, DatabaseConnectorAbstract):
            raise TypeError()

        # And now you can rely that database_connector either implements all
        # required methods or raises not implemented exception

类层次结构

Python异常。请看一下那里的层次结构。

ArithmeticError 通用的Exception,在某些情况下可以变得具体到说FloatingPointError。在处理异常时这非常有用。

您可以在.NET表单上更好地了解此功能,当添加到表单中时,对象必须是Control的一个实例,但实际上可以是任何其他类型的对象。整个重点是该对象既是DataGridView又是Control(并实现了所有方法和属性)。这与抽象类和接口密切相关,许多现实生活的例子都可以是HTML元素:

class HtmlElement: pass # Provides basic escaping
class HtmlInput(HtmlElement): pass # Adds handling for values and types
class HtmlSelect(HtmlInput): pass # Select is input with multiple options
class HtmlContainer(HtmlElement): pass # div,p... can contain unlimited number of HtmlElements
class HtmlForm(HtmlContainer): pass # Handles action, method, onsubmit

我尽可能地简化了它,所以请随意在评论中提问。


你刚刚是不是把PHP/Perl的变量和Python的类混在一起了? :D - Niklas R
@NiklasR 哈哈,确实是我做错了 :D 应该已经修复了 :) - Vyktor

1

由于您主要关注大局而不是类设计的机制,因此您可能希望熟悉面向对象设计的S.O.L.I.D.原则。这不是严格的程序,而是一组规则,以支持您自己的判断和品味。

要点是一个类代表了单一职责(S)。它只做一件事情并且做得很好。它应该代表一个抽象,最好是代表你的应用逻辑的一部分(封装行为和数据以支持该行为)。它也可以是多个相关数据字段的聚合抽象。类是这种封装的单位,并负责维护您抽象的不变量。
构建类的方法是既要开放扩展性又要关闭修改性(O)。确定类依赖项中可能发生的更改(接口和实现中使用的类型或常量)。您希望接口足够完整,以便可以扩展它,但同时希望其实现足够稳健,以便不必为此进行更改。
层次结构是通过继承或组合构建的。关键原则是仅使用继承来模拟严格的Liskov替换原则(L)。这是一种花哨的说法,意味着您仅使用继承来表示is-a关系。对于其他任何东西(除了一些技术上的例外,以获得一些小的实现优势),都应使用组合。这将使系统尽可能松散耦合。
在某些时候,许多不同的客户端可能因不同的原因而依赖于您的类。这将增加您的类层次结构,并且层次结构中较低级别的一些类可能会具有过大(“fat”)的接口。当发生这种情况时(实际上这是一种品味和判断的问题),您可以将通用类接口分成许多特定于客户端的接口(I)。
随着层次结构的进一步增长,它可能看起来像一个金字塔,其中基本类位于顶部,其子类或组合位于其下方。这意味着您的高级应用程序层将依赖于低级细节。您可以通过让高级层和低级层都依赖于抽象(即接口,在C++中例如可以实现为抽象类或模板参数)来避免这种脆性。这种依赖反转(D)再次有助于放松应用程序各个部分之间的耦合。

这就是:五个坚实的建议,它们更多或少与语言无关,并经受住了时间的考验。软件设计很难,这些规则可以让您避免最常见的问题类型,其他一切都需要通过实践来获得。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接