65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 面向对象编程

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (37投票s)

2014年10月30日

CPOL

28分钟阅读

viewsIcon

176652

downloadIcon

1330

C++ 面向对象编程的简短介绍,以易于理解的方式展示了对象和虚函数的基础知识。

重要更正

在本文于 2014 年 11 月 24 日之前的两个版本中,我曾声明在 HumanInteractions8/HumanInteractions9 示例中不需要虚析构函数。这是不正确的,我希望早期的读者能够以某种方式意识到我的错误,这样他们就不会犯同样的错误。我对这个错误声明深表歉意。

引言

多年来,我看到许多人说不建议用 C++ 教授面向对象编程 (OOP) 基础知识,因为该语言的复杂性。Java 似乎是标准,虽然我对此选择没有异议,但我每次听到第一个断言时,我仍然会翻白眼。

C++ 教程似乎随处可见,而其他语言现在更受关注,但我还是觉得有必要为初学者写一篇小型 C++ OOP 教程,直击面向对象的精髓。我不会涉及太多复杂性,因为入门不应该关注不必要的项目。据我所知,这种性质的教程并不常见,所以让我们开始吧。

如果您是初学者,请立即跳到下一节。以下段落是为了让更高级的程序员理解我的方法。

在许多方面,这是对像 Akhil Mittal 的文章以及我读过的其他几篇 OOP 介绍文章的回应,也是对 C++ 的反驳。因此,我努力将行话降到最低,并专注于初学者需要了解的,以开始理解更大的 C++ 编程图景。我相信有一本编程书籍采用了类似以下的方法,但我已经忘记了书名。我遇到的其他所有书籍在我看来都不适合初学者,尽管以下内容可能也会有一些粗糙之处,但我试图让它成为新程序员可以求助的,以相对快速地掌握必要概念的东西。

和许多人一样,我的编程兴趣始于很久以前。那时学习常常是抄写杂志上的代码,然后运行并修复由于手动输入而引入的所有拼写错误。令人惊讶的是,即使杂志是半专业的,它们的代码也常常包含错误。因此,以及其他问题,许多例子最初都超出了我的理解,但反复敲打我的脑袋的过程最终带来了顿悟。我相信这就是真正学习的本质,我希望以下内容能够向这一传统致敬,同时避免引入延长我的学习过程(但也使其更深入)的错误。

准备工作

程序员需要两样东西:一台电脑和一个编译器。如果您的电脑操作系统 (OS) 是 Windows 并且您手头拮据,我推荐微软的 Visual Studio Community Edition (VSCE)。微软以免费的价格提供了一款非常棒的工具供我们使用。如果您使用其他操作系统,由于我对这些平台不熟悉,我无法给出任何建议。

使用标准安装程序设置好编译器后,下一步是在其中创建一个项目。要在 VSCE 中执行此操作,请转到“文件 -> 新建项目”,然后选择“Visual C++ -> 通用 -> 空项目”。将其放置在您系统上的任何位置,尽管我建议此时创建一个具有前瞻性的子目录结构。当然,没有经验您就不知道什么是前瞻性的方法。

我的建议是将本教程的项目放在名为“[User]\Programs\MyProgs\InitialLearning”的子目录中。对于下面的内容,一个项目就足够了,但如果您愿意,可以将每个步骤都制作成一个单独的项目,每个项目都是 InitialLearning 目录下的一个子目录。

在我的系统中,“Programs”子目录只包含子目录,包括“Backups”、“Libraries”、“MyProgs”、“OthersProgs”、“Misc”和“TheoryAndExamples”。这种布局的最终原因不是我们工作所必需的,但将来会需要。复杂的程序几乎总是需要外部库,这种子目录排列使得查找这些库比它们分散在硬盘上更容易。最终,这在 Visual Studio 中会减少鼠标点击次数。

面向对象编程 (OOP)

编码的目的是解决问题,即使只是为了让用户娱乐一段时间。OOP 是一种尽可能直接地将问题反映在代码中的方法。理解这个陈述的最佳方式是通过一个例子,所以让我们模拟一个:人际互动。

在我们的虚构案例中,我们将代表两个人互相交谈。我们只希望一个人说“早上好,珍妮!”,另一个人回答“你好,乔!”。因此,这个项目的名称请使用“HumanInteraction”。在 Visual Studio 中创建新项目时,如果您位于前面提到的“InitialLearning”子目录中并创建一个名为“HumanInteraction”的项目,Visual Studio 将在名为“HumanInteraction”的子目录中创建它,因此您无需手动创建位置。

在 C++ 中,我们使用关键字 class 来表示所需的对象类型。我们正在建模人类,因此 C++ 中的等价物是 class Human。例程,有时称为“函数”,是类用来完成任务的方法。在我们的例子中,我们希望一个人“说话”给另一个人,所以“talk”是我们第一个函数的很好的描述性名称。

对象具有属性,就像现实世界一样。例如,前面场景中的属性是人物的名字:“珍妮”和“乔”。

让我们开始用 C++ 表达一个“人类”对象,带有一个“姓名”属性。以下代码包含我尚未介绍的内容,但您可能会根据使用的词语理解其基本概念。它们将在后面解释。

class Human {
   private:
      std::string nameC;

   public:
      Human(const std::string & name) : nameC(name) { }
   };

从上到下阅读时,您会注意到的第一件新事物是左大括号 ({)。大括号是向编译器描述对象和“作用域”的主要方式。在这种情况下,左大括号和右大括号之间(后面带有“;”的那个)的所有内容都是“Human”类的定义。

在上一段中,“作用域”的意思是,除非您采取措施阻止这种情况发生,否则在大括号内声明的项将在右大括号处被销毁。如果本文没有太长,以后可能会有更多关于此的内容。

Human 声明中的下一个项是 private:。这意味着该部分中的项只能由类本身使用。这个人的名字已在此部分中声明,听到这个消息后,第一个合乎逻辑的想法是您的名字对您来说不是“私有的”——您的所有朋友都知道它。这是真的,但他们通过互动才了解您的名字。大多数陌生人不会知道您的身份,除非您将其写在贴纸上并贴在自己身上。

将尽可能多的类项目设为 private 是个好主意。这可以防止其他对象随意修改它们。如果您将“name”设为 public,任何其他对象都可以随意更改它——这绝对是您不希望发生的!

这个“名字”被声明为 std::string。C++ 有一个标准库来让您的生活更轻松,该库中的项目“包含”在所谓的 std 命名空间中。我在这里不会深入讨论命名空间,因为它会使我们偏离当前主题太远,并带我们进入那些“复杂性”。但我会提到有一种方法可以消除 string 前面的 std::,并减少打字。只需在类前面添加一行:

   using namespace std;

当您开始处理头文件(我在此不涉及)时,请注意,以这种方式在其中“使用”命名空间不是一个好主意。您应该在头文件中明确指定所有内容(即,“std::vector”),但在 .cpp 文件中使用 using namespace 只有在它们作为头文件被 #include 到其他单元中时才会出现问题,而我,以及我见过的几乎所有人,都强烈不鼓励这样做。

(有些人对标准库做了例外,在头文件中包含了 `using namespace std;`,但我建议您在这样做之前获得更多经验。我没有这样做,因为我认为这是一个坏习惯。)

我还应该解释 nameC 末尾的 C

大型程序通常变得难以理解,能够区分作为类永久组成部分的变量(例如“name”)与其他仅在单个函数中使用的变量是很有帮助的。我区分这些变量的方法是在变量名末尾使用 C 来表示它是“Class”的一部分。您经常会遇到使用前缀 m_ 来做同样事情的代码,其中 m_ 代表“member”。在我看来,“C”比“m_”键入的次数少,并且在可行的情况下,键入次数少更好。

当然,这个 string 是一个我们可以放置人名的对象。在我们的例子中,其中一个名字将是“Joe”,另一个将是“Jennie”。字符串只是在计算机内存中背靠背放置的字符。

下一行是 public:。正如您可能从我们之前关于 private 的讨论中猜到的那样,放置在此部分中的项可以被其他对象访问。

下一行中的 Human 是一个非常重要的项。它被称为“构造函数”,是必须调用的方法,以便在计算机内存中创建 Human。关于构造函数,此时可以说更多,例如,如果您希望以一种通常不需要的非常特定的方式进行构造,它们可以被设置为私有。它们也可以被重载,我们稍后将讨论。

在我们的例子中,我在 Human 后面括号中放置了 const std::string & name。这是构造函数的调用者必须提供的一个项,在此处定义是为了强制调用者发送它。const 限定符表示 std::string 不会被例程修改。这种限定通常允许编译器优化代码以获得更好的速度和效率。

在 C++ 中,我们构造函数括号中的项称为“参数”(argument)。它并不是真的在“争论”什么;这个词可能是因为它意味着“论点”之类的东西而被选中的。它是“说话者”和“被说话者”之间传递的东西。

有趣的是,我不知道有更好的词来表达这个概念,尽管“参数”并没有很好地表达预期的含义。短语“communication point”,可以缩写为“compoint”,可能更好,但“参数”被接受,并且是您需要知道的词。(韦氏词典的第六个定义可能是在编程语言采用这个词之后才出现的。)

紧接着右括号的是一个冒号,:。构造函数需要在运行构造函数体内的任何代码之前以更高效的方式初始化类变量,而“:”和后面的大括号 ({) 之间的一切都是 C++ 实现这一目的的方式。它们被称为“初始化列表”。在这个初始化列表中,nameC 类变量被设置为调用对象将 name 参数设置为的值。

然后,在初始化列表之后,定义了构造函数的主体。在这种情况下,构造函数不需要做任何事情,因此它是空的。因此是 { }

最后,类定义以“};”结束。你也可以说那是它的作用域的结束,用我们之前说过的一个词。

在填充 talk 函数之前,让我介绍一个每个程序都以某种方式需要的例程:main 函数。C++ 标准要求每个程序都必须有一个 main 函数。

main 函数开始初始化——并完成终止——程序中使用的绝大多数对象和逻辑。它是创建和销毁我们 Humans 的例程。如果你愿意,可以把它看作是“上帝”函数。

我说 main 初始化并终止了大部分对象。例外情况是 global 项,我在这里也不会深入讨论。

所有这些准备工作都已完成,接下来是整个程序。要在 Visual Studio 中使用它,请右键单击右侧窗格中的“源文件”过滤器(除非您修改了默认屏幕布局),然后选择“添加 -> 新建项”。然后选择“C++ 文件 (.cpp)”选项。您可以将其命名为“Source”,或者根据需要重命名为“main”或其他名称。按下“添加”按钮后,编辑窗口中会出现一个新文件。将以下内容复制并粘贴到其中:

//HumanInteractions1:
//In code, lines that begin like this one, with "//", are comments.
//You can put anything in them you wish, and they won't make it into the executable.
//Therefore, you can include them in the copy/paste without any problems.

//There is another way to do comments in C++: use "/* ... */" without the quotes, where
//the "..." is everything you want in the comment block. That can be multiple lines.
//The following is an example of the second type of comment:

/*The reason for this comment is to say that the following two '#include' lines tell the
  compiler that it needs to include those files ("string" and "iostream") during the steps
  it takes to create the program.*/

//The 'string' file has all the necessary items in it to use the std::string objects
//I mentioned.  If you comment out the 'iostream' include, the compiler will complain
//that the the std namespace doesn't contain a 'cout', which is used throughout
//the code to show the text on the screen.

#include <string>
#include <iostream>

class Human {
   private:
      std::string nameC;

   public:
      Human(const std::string & name) : nameC(name) { }

      std::string name() const { return nameC; }
      
      void talkTo(const Human & person) const {
         std::cout << nameC << " says: Hello, " << person.name() << "!" << std::endl;
         }
   };

int main() {
   Human joe("Joe");
   Human jennie("Jennie");
   joe.talkTo(jennie);
   jennie.talkTo(joe);
   std::cout << std::endl << "Press 'Enter' to exit";
   std::cin.ignore();
   return 0;
   }

下载 HumanInteractions1.zip - 9KB

如果您在 Visual Studio 中按下“本地 Windows 调试器”按钮,您应该会看到以下内容:

恭喜!您刚刚执行了您的第一个面向对象程序。(排除复制/粘贴错误或其他困难,这些问题应该可以通过重新阅读说明来解决。)

我应该提一下,std::string name() const { return nameC; } 行中的 const 告诉编译器该函数不会以任何方式改变对象的状态。在这种情况下,我们只是返回 nameC 变量的一个副本,返回副本不会修改原始变量。与前面提到的 const 类似,这在某些情况下允许编译器优化以获得额外的速度。(talkTo 后面的第二个 const 也是如此。)

我还应该简单介绍一下 cout。它就是控制台输出,也就是截图中显示的窗口。在代码中,<< 可以理解为将后面的项发送到控制台。例如,std::cout << std::endl << "Press 'Enter' to exit"; 发送一个换行符(就像老式打字机上的“回车”或文字处理器中的“Enter”),后面跟着短语“Press 'Enter' to exit.”。std::endl 后面的 << 允许后面的字符串与 std::endl 连接在一起。

如果您想减少打字,以下是包含 using namespace std; 的程序:

#include <string>
#include <iostream>

using namespace std;

class Human {
   private:
      string nameC;

   public:
      Human(const string & name) : nameC(name) { }

      string name() const { return nameC; }
      
      void talkTo(const Human & person) const {
         cout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }
   };

int main() {
   Human joe("Joe");
   Human jennie("Jennie");
   joe.talkTo(jennie);
   jennie.talkTo(joe);
   cout << endl << "Press 'Enter' to exit";
   cin.ignore();
   return 0;
   }

从前面的讨论中,您可能已经对这个程序中发生了什么有了一个概念,但让我再谈几点。

首先,如前所述,所有内容都在 main 中启动。创建了一个名为“Joe”的 Human,然后创建了“Jennie”。Joe 与 Jennie“交谈”,她做出了回应。

请注意,我将函数名改为了 talkTo 而不是我之前说的 talk。原因是我意识到在这种情况下 talkTotalk 更具描述性,更丰富。代码中更好的描述使其更容易调试。以这种方式更改事物的过程称为“重构”。

在珍妮和乔“交谈”之后,程序会等待您按下“Enter”才能终止。

您可能会问的一个问题是,为什么代码中的“Joe”也被称为 joe,而不是大写。答案是,“Joe”(带引号)是计算机对象 joe 的“属性”。该属性可以更改为任何东西,例如“Mary”,但计算机仍然会通过变量 joe 访问它。

另一个合理的问题是,为什么我没有将对象命名为 Joe 而不是 joe?原因是为了阅读简单起见,出于习惯和一致性。

我,以及大多数其他程序员,喜欢区分对象声明和变量。一个简单的方法是使用大写字母表示对象声明,并使用驼峰式大小写表示变量中的这些声明的实例化。您将来会遇到解决这个问题的其他方法,但我提出的方法在许多地方都有使用。

还有许多其他缩进代码的方式。例如,最常用的一种如下:

#include <string>
#include <iostream>

using namespace std;

class Human
{
private:
   string nameC;

public:
   Human(const string & name) : nameC(name) { }

   string name() const { return nameC; }
   
   void talkTo(const Human & person) const
   {
      cout << nameC << " says: Hello, " << person.name() << "!" << endl;
   }
};

int main()
{
   Human joe("Joe");
   Human jennie("Jennie");
   joe.talkTo(jennie);
   jennie.talkTo(joe);
   cout << endl << "Press 'Enter' to exit";
   cin.ignore();
   return 0;
}

我有自己使用早期风格的逻辑原因——即缩进级别会自动划分与缩进“块”相关的所有内容。我将右括号视为块的一部分,这使得查找下一个缩进级别变得更加简单。但围绕这个问题爆发过“圣战”,所以当你看到其他风格时,接受它们并继续前进。

对象关系

前面的程序介绍了以面向对象方式进行 C++ 编程的核心。但我还必须补充一个主题,因为如果不理解它,您的编程知识将在一段时间内受到限制。

对象通常与其他对象相关联,就像现实生活中的事物与其他事物相关联一样。很多时候,您需要创建一组有些相似但并非完全相同的对象列表,然后根据这些差异采取不同的行动。

举个例子,让我们来处理一群人互相说“嗨”的场景。我会触及刻板印象,而且做得不好,所以请包涵,并随时在评论中纠正所有错误。

如您所知,几乎每种文化都有自己的语言或地方口音。在非面向对象编程语言中,处理这些差异通常比在面向对象语言中困难得多。事实上,面向对象的方法在编码便利性和大多数情况下的运行时性能方面具有巨大优势。

想象一下你有一个房间里坐满了人,他们互相说“嗨”。让我们把它变成一个不太可能但很容易在脑海中描绘的场景。这些人围成一圈,第一个人依次向所有其他人说“嗨”,从左边的人开始,他可以被认为是二号人物。一号人物向所有人说“嗨”之后,二号人物然后向一号人物说“嗨”,然后是三号、四号等等,直到他/她说完,然后三号人物重复这个过程,然后是下一个人,直到每个人都互相打招呼。

现在,为了给它加上最后的转折,说话者将使用他们自己的文化词语,而不是“嗨”。我不会深入探讨完整的场景,因为我不知道它们(例如,如果我没记错的话,在日语中,“David”听起来更像“Dahvid”)。

为了说明面向对象如何简化编程,我需要展示非面向对象方法如何解决问题的基本原理。在这种方法中,程序必须遍历人员并确定他们的国籍/文化,同时执行 talk 例程。C++ 完全能够处理非面向对象程序,因此执行此操作的循环将如下所示。(我保留了一些面向对象的元素,以便更容易理解。)

   //The following line iterates over the person doing the talking:
   for (int talkingPerson=0; talkingPerson<numberOfPeople; ++talkingPerson) {
   
      //And this next line iterates over who is being spoken to:
      for (int talkedToPerson=0; talkedToPerson<numberOfPeople; ++talkedToPerson) {
      
         if (talkingPerson != talkedToPerson) { //Nobody says 'hi' to themselves
         
            //And finally, they do the actual talking, depending on the culture of the talker:
            if (talkingPerson.culture() == "Japanese") cout << "Konnichiwa, " <<
                        talkedToPerson.name();
            else if (talkingPerson.culture() == "German") cout << "Hallo, " <<
                        talkedToPerson.name();
            else if (talkingPerson.culture() == "Hawaiian") cout << "Aloha, " <<
                        talkedToPerson.name();
            //...
            }
            
         }
      }

正如我所说,为了便于理解,我使用了面向对象的元素。您不必理解代码所做的一切,但重要的是要注意,代码的内部部分,由 if 语句(我已加粗并斜体)组成,随着涉及的文化越来越多,很快就会变得非常长。计算机在每次迭代时都必须进行这些检查,这需要一点时间。对于我们来说,这些时间几乎微不足道,因为今天的计算机速度惊人,但时间仍然会流逝,并且可以减少。

面向对象大大简化了代码。循环变为:

   for (int talkingPerson=0; talkingPerson<numberOfPeople; ++talkingPerson) {
      for (int talkedToPerson=0; talkedToPerson<numberOfPeople; ++talkedToPerson) {
         if (talkingPerson != talkedToPerson) { //Nobody says 'hi' to themselves
            talkingPerson->talkTo(talkedToPerson);
            }
         }
      }

就是这样。所有的文化 if 处理都被消除了。而且增加更多的文化不会影响这部分代码。此外,无论是涉及一种文化还是一千种文化,每次循环所花费的时间都差不多。

指针/引用

在为我们的新场景开发一个可编译的“对话”程序并展示所涉及的细节之前,我还需要再谈一点:C++ 中“.”和“->”之间的区别。这是两种引用对象和变量的方法。

您在程序中创建的所有内容都存储在内存中的某个位置。例如,让我们来看看我们之前的 joe。它是在 main 中创建的一个对象,我们使用“.”语法访问其函数。(我说的是 joe.talkTo(jennie); 这一行。)

简单地说,假设运行该程序的计算机只有 1,000 个内存槽。而 joe 对象开始的内存位置在 #703。当程序编译时(还记得在 Visual Studio 中按下“本地 Windows 调试器”按钮吗?),当给定术语“joe”时,可执行文件就知道如何直接访问 #703。在这些情况下,您只需使用“.”语法。

但是,如果你愿意,还有一种方法可以访问 joe,那就是通过所谓的 pointer。将 joe 的*起始位置*放入另一个内存位置,并使用该辅助位置“指向”joe 是完全可以接受的。换句话说,我们可以将“703”放入那个其他位置。事实上,这样做通常非常有用。当以这种方式访问 joe 时,我们使用“->”语法。

为了简化起见,尽管并不总是正确的,但为了给出一个大致的心理图景:如果我们处理的是事物的真实位置,我们使用“.”。如果我们处理的是指向该位置的指针,我们使用“->”。(一个更正确的硬性规则是“如果我们处理的是指针并且没有解引用它,我们总是使用 -> 命名法来访问与对象关联的函数。”但您还不知道什么是“解引用”,所以假装没读到!)

这个简单的规则*确实*很简单,但由于指针可以灵活地变成非指针,而非指针(在某些情况下称为 引用,在其他情况下称为 对象变量)可以变成指针,所以很容易混淆。它们通过“取地址”运算符“&”和“解引用”运算符“*”进行转换。

“取地址”运算符“&”用于确定变量或对象的内存位置。而且,正如您可能猜测的那样,“解引用”运算符“*”用于将指针中保存的值视为变量或对象的直接位置。(现在您不必再假装了。)

不幸的是,这两个运算符在其他上下文中都有次要含义,这使得问题更加复杂。(或者“幸运的是”,对于 C++ 的更大范围来说,因为它们是有价值的构造,但它们使您的初始学习变得稍微困难。)“取地址”运算符“&”也可以用于划分一个名为 reference 的变量,而“解引用”运算符“*”可以用于指示一个名为 pointer 的变量。稍微熟悉之后,它们很容易区分,但我花了一段时间才适应它们,所以如果您没有立即理解,请再重新学习几次这个主题。您并不孤单。

(一般规则是,如果这些符号在“=”号的左侧,它们正在声明一个指针或引用变量。如果它们在右侧,它们正在解引用或取某个东西的地址。)

让我们重做之前的程序,让珍妮和乔使用这些构造重复地互相交谈。

//HumanInteractions2:
#include <string>
#include <iostream>

using namespace std;

class Human {
   private:
      string nameC;

   public:
      Human(const string & name) : nameC(name) { }

      string name() const { return nameC; }
      
      void talkTo(const Human & person) const {
         cout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }
   };

   
int main() {
   Human joe("Joe");
   Human jennie("Jennie");
   joe.talkTo(jennie);
   jennie.talkTo(joe);
   
   //Now for some new stuff! Let us get pointers to 'joe' and 'jennie'.
   
   //The following line creates a pointer to a Human object. The pointer is called
   //'pointerToJoe'. It is located in memory at the program's current stack position,
   //to give you a phrase for further study when you are ready for it.  You don't have
   //control over that memory position's location.
   //After declaring the pointer, the 'address of' operator is used to get the location
   //of 'joe', and place it into memory at the address of the pointer.
   Human * pointerToJoe = &joe;
   
   //Now do the same for jennie:
   Human * pointerToJennie = &jennie;
   
   //And use these pointers to 'talk' to the objects:
   pointerToJoe->talkTo(jennie);
   pointerToJennie->talkTo(joe);
   
   //And to make it even more demanding, brain-wise, let us 'dereference' the pointers
   //for the 'talkTo' arguments:
   pointerToJoe->talkTo(*pointerToJennie);
   pointerToJennie->talkTo(*pointerToJoe);
   
   //As if that syntax wasn't bad enough to remember, we can also get references from
   //those pointers (which is what we really did in the last example, where we used
   //"*pointerToJennie")
   Human & referenceToJoe = *pointerToJoe;
   Human & referenceToJennie = *pointerToJennie;
   
   //If it helps, in my head I almost always read "*pointerToJennie" and similar
   //dereferences as: "What's pointed to by the 'pointerToJennie.'"
   //It's far more verbose, but it aids my mental understanding, even after years of
   //seeing them.
   
   //And with these references we use the original "." syntax again:
   referenceToJoe.talkTo(referenceToJennie);
   referenceToJennie.talkTo(referenceToJoe);   
   
   cout << endl << "Press 'Enter' to exit";
   cin.ignore();
   return 0;
   }

下载 HumanInteractions2.zip - 9KB

从输出可以看出,所有这些都是等价的,只是语法不同。

指针和引用是功能强大但要求严格的工具。像之前那样使用它们是安全的,但随着您深入 C++,在许多情况下,指针会像毒蛇一样咬您的脚踝,这种痛苦会让人记忆犹新。引用也有一些相关的“陷阱”,但它们通常不会咬得那么疼。(这是一篇关于指针的文章这是一篇关于引用问题的文章,在您完成本教程后可继续学习。)

虚拟(几乎是魔法!)

我提到指针的原因是,在 C++ 中它们可以以一种非常强大的方式使用:调用 virtual functions

为了理解虚函数,让我退后一步,谈谈另一个项目:基类和子类。

还记得那组 if 语句吗?在我们的“谈话”场景中,随着涉及的文化数量的增加,这些语句会迅速增长。这些语句处理不同类型的人类,理解基类和子类的一个简单方法是,将一个泛型 Human 视为基类,而一个 ChinesePerson 则是 Human 的子类。(AmericanPerson 也是一个子类,所以不含对任何人的优越感。)

这是 C++ 中表达的关系

class Human {
 
   };

class ChinesePerson : public Human {

   };

没有理由就此止步,尽管就代码而言我将止步。我们可以从 ChinesePerson 派生出 NorthernChinesePerson,如果愿意,还可以从该类进一步派生出更具体的北方人类型。

根据前面的代码定义,ChinesePerson 可以访问 Human 中所有 public 的项目,并且也可以访问 Human 类中定义为 protected 的任何内容。

为了提供更多见解,我将快速编写一个程序。

几乎每个人都会“说话”,所以我将围绕这一点修改原始程序以澄清问题。首先,让我们创建一个程序,其中基类有一个 talkTo 函数作为公共方法。

//HumanInteractions3:
#include <string>
#include <iostream>

using namespace std;


class Human {
   private:
      string nameC;

   public:
      Human(const string & name) : nameC(name) { }

      string name() const { return nameC; }
      
      void talkTo(const Human & person) const {
         cout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }
   };


class ChinesePerson : public Human {
   public:
      ChinesePerson(const string & name) : Human(name) { }
   };


int main() {
   Human joe("Joe");
   ChinesePerson li("Li");
   
   //By deriving 'joe' from 'Human', and 'li' from 'ChinesePeople', I don't mean to 
   //say Joe is human and Chinese people are a subset of humanity. That will be fixed
   //in a coming iteration of this program. Doing it this way allows me to make a
   //point about 'private' and 'public' that I can't in the correct version.

   li.talkTo(joe);

   //And 'joe' can talk to 'li':
   joe.talkTo(li);
   
   cout << endl << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

下载 HumanInteractions3.zip - 9KB

运行后,您会看到它几乎和以前一样执行。

joeli 都可以使用 talkTo 函数互相交谈。但如果 talkTo 被移到 Humanprivate 部分,只有 joe 才能使用它。将 Human 定义更改为以下内容,然后尝试重新运行程序:

class Human {
   private:
      string nameC;
      
      void talkTo(const Human & person) const {
         cout << nameC << " says: " << "Hello, " << person.name() << "!" << endl;
         }

   public:
      Human(const string & name) : nameC(name) { }

      string name() const { return nameC; }
   };

现在您会收到一个错误,指出 li.talkTo(joe); 行中的 Human::talkTo“不可访问”。

那是因为只有 Human 可以访问其类中 private 部分的项。所以我们再次将 talkTo 函数设为 public

现在我们开始面对我们最初的有趣问题:如何让李用他自己的语言说话。现在他只能说“你好,[名字]”。

解决方案的第一次迭代是给 ChinesePeople 自己的 talkTo 函数。

//HumanInteractions4:
#include <string>
#include <iostream>

using namespace std;


class Human {
   protected:
      string nameC;

   public:
      Human(const string & name) : nameC(name) { }

      string name() const { return nameC; }

      void talkTo(const Human & person) const {
         cout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }

   };


class ChinesePerson : public Human {
   public:
      ChinesePerson(const string & name) : Human(name) { }

      void talkTo(const Human & person) const {
         cout << nameC << " says: Néih hóu, " << person.name() << "!" << endl;
         }
   };


int main() {
   Human joe("Joe");
   ChinesePerson li("Li");

   li.talkTo(joe);

   //And 'joe' can talk to 'li':
   joe.talkTo(li);
   
   cout << endl << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

下载 HumanInteractions4.zip - 9KB

当您执行此程序时,请注意“Néih hóu”

对象*确实*互相交谈了,但是翻译出了大问题!

问题归结为字符集,以及不同语言如何在计算机内存中用“一”和“零”表示。我不会涉及这个主题,除了提供一个解决方案(没有太多解释)来解决这个问题。

Google、Bing、DuckDuckGo 和其他搜索引擎绝对是程序员最常用的工具之一。搜索“visual studio console wstring”会弹出 这个 StackOverflow 页面,它提供了一种规避问题的方法。

经验告诉我,部分问题是由于使用了 std::string 而不是 std::wstring,这就是我选择该搜索词的原因。为了解决这个问题,程序需要重写如下:

//HumanInteractions5:
#include <string>
#include <iostream>
#include <io.h>
#include <fcntl.h>

using namespace std;


class Human {
   protected:
      wstring nameC;

   public:
      Human(const wstring & name) : nameC(name) { }

      wstring name() const { return nameC; }

      void talkTo(const Human & person) const {
         wcout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }

   };


class ChinesePerson : public Human {
   public:
      ChinesePerson(const wstring & name) : Human(name) { }

      void talkTo(const Human & person) const {
         wcout << nameC << " says: Néih hóu, " << person.name() << "!" << endl;
         }
   };


int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);
   Human joe(L"Joe");   //The "L" tells the compiler to treat the text as a wide string
   ChinesePerson li(L"Li");

   li.talkTo(joe);

   //And 'joe' can talk to 'li':
   joe.talkTo(li);
   
   wcout << endl << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

下载 HumanInteractions5.zip - 9KB

执行后,得到预期结果:

不幸的是,我们无法在不付出更多努力的情况下使用真正的汉字(据我所知),所以我就保留这种文本状态了。

(实际上,再多看一点,可能不需要太多。似乎您所要做的就是修改注册表,以便控制台可以使用像 Arial Unicode MS 这样的字体。如果您想再玩玩这个,我相信这些是此实例的字符:你好。现在我们回到工作!)

终于!介绍了!

至此,我们有了解决“对话”问题的背景材料。由于我还没有展示 virtual 示例,所以让我先扩展前面的内容,以说明如何在没有它的情况下解决问题。我们将填充那个冗长的 if 循环,并进行一些修改。请注意,我已将 talkTo 例程从 Human 类中移除,以强调每种文化类型都在进行自己的“对话”。我还展示了如何遍历列表并以这种方式进行对话:

//HumanInteractions6:
#include <string>
#include <iostream>
#include <io.h>
#include <fcntl.h>
//New introduction: vector:
#include <vector>

using namespace std;


//The following enum is a way to associate a culture of a person to a number,
//so the computer can quickly look it up:

enum class Culture {
   Chinese = 1,   //You can set an enum value to some, or all elements like this
   American,      //If you don't set the value, the compiler will do so for you.
                  //'American' will probably be set to '2' automatically.
   Unknown        //And this one will be set to '3', but it doesn't matter to you,
                  //because all that is cared about is they are different values.
                  //(You can set two values to the same number, but don't expect
                  //it to work the way you are expecting without a lot more effort.
                  //For example, change 'American' to 'American = 1' without changing
                  //the Chinese definition. That change can never work as expected.)
   };


class Human {
   protected:
      wstring nameC;
      //Note the addition of the following 'Culture' variable:
      Culture cultureC;

   public:
      //The 'Human' constructor now requires the culture of the person to be given:
      Human(const wstring & name, Culture culture) : nameC(name), cultureC(culture) { }
      
      //If you are asking, 'culture' is passed 'by value' and 'name' is passed 'by reference'
      //in the previous constructor because passing variables that are the size of the
      //computer's registers is more efficient than passing the address to that variable
      //and then looking it up inside the function.
      //
      //On the other hand, in the case of the 'name' it is much faster to deal with addresses
      //than it is to construct a temporary string and pass it, because the string is
      //much bigger than the size of a register, and requires construction, and later,
      //destruction.
      //
      //There are cases in which you would pass a reference (or pointer) to something small,
      //like 'culture,' into a function, but they aren't very common. Specifically, if you
      //needed to change the value in the called function, and have the calling function
      //then use the changed value, you could do so. But usually in those cases it is better
      //to make the variable part of the class itself, so it wouldn't need to be passed.
      
      wstring name() const { return nameC; }

      //And the following allows you to retrieve the culture of the person:
      Culture culture() const { return cultureC; }
   };


class AmericanPerson : public Human {
   public:
      //The 'AmericanPerson' constructor now passes the correct enumeration to 'Human'
      AmericanPerson(const wstring & name) : Human(name, Culture::American) { }

      void talkTo(const Human & person) const {
         wcout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }
   };


class ChinesePerson : public Human {
   public:
      //And the ChinesePerson's culture is set like this:
      ChinesePerson(const wstring & name) : Human(name, Culture::Chinese) { }

      void talkTo(const Human & person) const {
         wcout << nameC << L" says: Néih hóu, " << person.name() << L"!" << endl;
         }
   };


int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);
   AmericanPerson joe(L"Joe");
   ChinesePerson li(L"Li");

   //We can still do the talking the original way, even though 'Human' no longer has a
   //'talkTo' function:
   li.talkTo(joe);
   joe.talkTo(li);

   //But if we wanted to iterate through a list of Humans, and do the talking, we have
   //to put in checks for the culture, as an earlier code snippet indicated:
   
   wcout << endl << "Now talking by iterating over the list:" << endl << endl;

   //First, create a list of people. One good library tool for this is a 'vector.'
   //The following line creates a vector of pointers to our Humans:
   vector<Human*> people;

   //And the following puts pointers to our people on that vector:
   people.push_back(&li);
   people.push_back(&joe);

   //Now, for the talking.  I will do it with a series of 'for' loops that
   //iterate over the speaker and listeners. I don't expect you to understand
   //the loops, but you can use them as a springboard for deeper investigation:
   
   for (int speaker=0, numPeople=people.size(); speaker<numPeople; ++speaker) {

      for (int listener=0; listener<numPeople; ++listener) {

         if (listener != speaker) { //Nobody talks to themselves
      
            Culture culture = people[speaker]->culture();
            
            if (culture == Culture::American) {
               //(I don't expect you to understand the 'static_cast', and other items
               //in the following lines, either, but they can lead to more C++ learning.)
               
               AmericanPerson * ap = static_cast<AmericanPerson*>(people[speaker]);
               ap->talkTo(*people[listener]);
               }
            
            else if (culture == Culture::Chinese) {
               ChinesePerson * cp = static_cast<ChinesePerson*>(people[speaker]);
               cp->talkTo(*people[listener]);
               }

            else {
               wcout << "Culture unknown - programming error" << endl;
               }
            }
         }
      }

   
   wcout << endl << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

下载 HumanInteractions6.zip - 9KB

程序不再那么小了,但它展示了没有虚函数编码的复杂性,并且达到了预期效果。

我们现在面临的问题是,我们希望遍历一个 Human 列表,并让每个人轮流“说话”,但“说话”功能取决于进行说话的 Human 类型。我们真正想要的是一个通用的 talkTo 例程,子类可以根据自己的意愿实现它,并且计算机可以根据类型自动调用它。

这就是 virtual functions 的用武之地。标记为 virtual 的公共例程(或受保护例程)可以被子类覆盖,并且当它们*通过指向声明该函数的基类的指针*被调用时,程序将在幕后找出要调用的正确函数。我将添加更多类型的 Human,并解决最初的问题,并且不使用之前方法中的 Culture enum

//HumanInteractions7:
#include <string>
#include <iostream>
#include <vector>
#include <io.h>
#include <fcntl.h>

using namespace std;


class Human {
   protected:
      wstring nameC;

   public:
      Human(const wstring & name) : nameC(name) { }

      wstring name() const { return nameC; }

      virtual void talkTo(const Human & person) const { }
      //The previous 'talkTo' definition could have forced every sub-class of 'Human'
      //to implement 'talkto' for themselves by adding a "= 0" towards the end of the
      //statement, like:
      //virtual void talkTo(const Human & person) const = 0 { }

      //As it is, if a sub-class doesn't implement it, this "{ }" do-nothing implentation
      //will be called, and that person won't appear to 'talk'.

      //Adding "= 0" makes the class into a "Pure Virtual" class, which can't be
      //instantiated by itself.  This is useful for some situations.
   };


class AmericanPerson : public Human {
   public:
      AmericanPerson(const wstring & name) : Human(name) { }

      //The 'override' in the following definition allows the compiler to let us know if we
      //have mistyped the name of the function, or its arguments, and it doesn't match a
      //function defined in a base class. Without 'override' it is MUCH more difficult to
      //find typos of that nature.
      
      void talkTo(const Human & person) const override {
         wcout << nameC << " says: Hello, " << person.name() << "!" << endl;
         }
   };


class ChinesePerson : public Human {
   public:
      ChinesePerson(const wstring & name) : Human(name) { }

      void talkTo(const Human & person) const override {
         wcout << nameC << L" says: Néih hóu, " << person.name() << L"!" << endl;
         }
   };


class MexicanPerson : public Human {
   public:
      MexicanPerson(const wstring & name) : Human(name) { }

      void talkTo(const Human & person) const override {
         wcout << nameC << L" says: ¡Hola!, " << person.name() << L"!" << endl;
         }
   };


class GhettoSlanger : public Human {
   public:
      GhettoSlanger(const wstring & name) : Human(name) { }

      void talkTo(const Human & person) const override {
         wcout << nameC << L" says: Yo, Homey! Whassup?" << endl;
         }
   };


class JapanesePerson : public Human {
   public:
      JapanesePerson(const wstring & name) : Human(name) { }

      void talkTo(const Human & person) const override {
         wcout << nameC << L" says: Konnichiwa, " << person.name() << L"!" << endl;
         }
   };


int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);

   AmericanPerson joe(L"Joe");
   ChinesePerson li(L"Li");
   MexicanPerson jose(L"Jose");
   GhettoSlanger mark(L"Mark");
   JapanesePerson hana(L"Hana");

   vector<Human*> people;
   people.push_back(&joe);
   people.push_back(&li);
   people.push_back(&jose);
   people.push_back(&mark);
   people.push_back(&hana);

   for (int speaker=0, numPeople=people.size(); speaker<numPeople; ++speaker) {
      for (int listener=0; listener<numPeople; ++listener) {
         if (speaker != listener) {
            people[speaker]->talkTo(*people[listener]);
            }
         }
      wcout << endl;
      }
   
   wcout << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

下载 HumanInteractions7.zip - 9KB

至此,解决方案完成。您已经了解了如何在 C++ 中创建对象,以及虚函数如何让您在许多情况下消除逻辑检查。我刚刚向您展示的是 C++ 面向对象的核心,在*许多*情况下都非常有用。我希望这能帮助您比我更快地达到一定的精通程度。

在结束之前,我将提到您经常会看到通过 new 创建项目。这样做将消除前面 main 函数中的一些代码。new 在计算机的“堆”内存中的某个位置创建一个对象,以便为您提供另一个术语供进一步研究。然后它将该位置的地址(以指针的形式)返回给您。

知道了这一点,第一次迭代将是以下内容,但它非常幼稚,您*绝不应该*这样做!(抱歉我大喊大叫,但这很重要。)

int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);

   vector<Human*> people;
   people.push_back(new AmericanPerson(L"Joe"));   //This stores a copy of the pointer
                                                   //into the vector
   people.push_back(new ChinesePerson (L"Li"));
   people.push_back(new MexicanPerson (L"Jose"));
   people.push_back(new GhettoSlanger (L"Mark"));
   people.push_back(new JapanesePerson (L"Hana"));

   for (int speaker=0, numPeople=people.size(); speaker<numPeople; ++speaker) {
      for (int listener=0; listener<numPeople; ++listener) {
         if (speaker != listener) {
            people[speaker]->talkTo(*people[listener]);
            }
         }
      wcout << endl;
      }
   
   wcout << "Press 'Enter' to exit";
   cin.ignore();

   return 0;
   }

这个解决方案消除了用变量名来称呼每个人的需要,并且比之前的代码更短,但问题是这样在作用域结束时,没有一个对象会被删除。之前的解决方案总是会调用对象的“析构函数”,但这个解决方案不会。

一个解决方案是修改程序如下:

//First, we need to modify the 'Human' class by giving it a virtual destructor,
//so the program will know that it needs to look up the correct destructor when it is called.
//The only change is the addition of one line:

class Human {
   protected:
      wstring nameC;

   public:
      Human(const wstring & name) : nameC(name) { }

      //New line:
      virtual ~Human() { }
      
      //In all of the previous examples the compiler created destructors for us without
      //any input on our part. Now we are taking control because the compiler
      //doesn't know what we need, but we do, and need to tell it about our requirement.

      wstring name() const { return nameC; }

      virtual void talkTo(const Human & person) const { }
   };


//And 'main' needs to be modified like this:

int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);

   vector<Human*> people;
   people.push_back(new AmericanPerson(L"Joe"));
   people.push_back(new ChinesePerson (L"Li"));
   people.push_back(new MexicanPerson (L"Jose"));
   people.push_back(new GhettoSlanger (L"Mark"));
   people.push_back(new JapanesePerson (L"Hana"));

   for (int speaker=0, numPeople=people.size(); speaker<numPeople; ++speaker) {
      for (int listener=0; listener<numPeople; ++listener) {
         if (speaker != listener) {
            people[speaker]->talkTo(*people[listener]);
            
            //By the way, we could change all the 'talkTo' functions to take pointers
            //instead of references, so the previous line would be a little cleaner,
            //without the "*":
            //"people[speaker]->talkTo(people[listener]);"
            //But then you would need to place a check in each of the functions to make
            //certain the pointer was valid, which looks like this if the pointer was named
            //'human': "if (human) { doTheTalking; }"
            }
         }
      wcout << endl;
      }
   
   wcout << "Press 'Enter' to exit";
   cin.ignore();

   while (!people.empty()) {
      delete people.back();
      people.pop_back();
      }

   return 0;
   }

下载 HumanInteractions8.zip - 9KB

现在,在最后,这些对象将被删除。但即使这个解决方案也不是最好的,因为如果在“*people.push_back(new JapanesePerson (L"Hana"));*”行和“*while (!people.empty()) {*”行之间发生坏事,那么“*while (!people.empty()) {*”行将永远不会被执行,并且对象将像以前一样“泄露”。

目前解决此问题的最佳范例是使用标准库中的一些辅助对象。我不会深入细节——您可以在线搜索下一个代码块中使用的单词。当您在其他人的作品中看到它时,您会更好地理解它,因为您已经在本 main 函数的重写中看到了它。

//HumanInteractions9:
//This approach requires another standard library file to be included.
//You can add it at the very top of the file, or you can even add it right before
//'main', as is done here.  The first is a better option, though, because it allows you
//to more easily see what is required for the entire file, instead of having to search
//around.
#include <memory>  //for 'unique_ptr'

int main() {
   _setmode(_fileno(stdout), _O_U16TEXT);

   //Create a vector of std::unique_ptr to Humans:
   vector<unique_ptr<Human>> people;

   //And populate it:
   people.push_back(make_unique<AmericanPerson>(L"Joe"));
   people.push_back(make_unique<ChinesePerson>(L"Li"));
   people.push_back(make_unique<MexicanPerson>(L"Jose"));
   people.push_back(make_unique<GhettoSlanger>(L"Mark"));
   people.push_back(make_unique<JapanesePerson>(L"Hana"));

   //Now iterate through everyone, and have them greet each other:
   for (int speaker=0, numPeople=people.size(); speaker<numPeople; ++speaker) {
      for (int listener=0; listener<numPeople; ++listener) {
         if (speaker != listener) {
            people[speaker]->talkTo(*people[listener]);
            }
         }
      wcout << endl;
      }
   
   wcout << "Press 'Enter' to exit";
   cin.ignore();

   //Note: We don't need to delete anything with this approach. It occurs automatically.
   //We DO continue to need the virtual destructor with this approach, which is something
   //I learned that required a secondary modification to this article.

   return 0;
   }

下载 HumanInteractions9.zip - 9KB

这就是本文我想分享的所有内容了,因为我的手指累了,是时候做点别的了。这篇文章总结了多年的工作和学习,希望能帮助你们中的一些人比我更容易理解这些概念。我知道我没有涉及头文件以及我提出的一些其他项目,但我会将这些留待另一个版本/迭代,如果需要的话。前面的概念比我遗漏的那些项目重要得多。

感谢阅读,祝编码愉快!

历史

  • 2014年11月24日
    • 在 HumanInteractions9 中,我读到的所有内容都让我认为 unique_ptr 会跟踪类型,因此无需虚析构函数即可调用正确的析构函数。但直觉告诉我(当然,在文章公开发布之后),所以我进行了测试。我的结论是错误的(我阅读的来源可能正确——我只是误读了它们)。因此,我将虚析构函数重新添加到了 Human 类中。对于误导本文早期的读者,我深表歉意,并希望我的错误声明能让他们知道,这样他们就不会因为早期版本而编写出有内存泄漏的代码。
    • 更正了 HumanInteractions2 中关于内存位置的注释。
    • 拆分了一个较长的段落,并进行了其他 minor 编辑。
    • 将推荐从 Visual Studio Express 改为 Visual Studio Community Edition,后者是在本文第一版发布后才提供的。当然,如果您想要一个占用磁盘空间较小的安装程序,Express Edition 将是您的选择,但我认为 Community Edition 提供了更多值得额外空间的功能。
    • 改进了所有代码中的 const 正确性,以树立更好的榜样。
  • 2014 年 11 月 4 日:在现有的 HumanInteractions8 示例中添加了虚析构函数,这是我在早期版本中忽略的。(HumanInteractions9 之前是 HumanInteractions8。)
© . All rights reserved.