引言
这个系列将讲解C++需知道的一切内容,涵盖这门语言的基础知识。本节将开始讲解C++的面向对象特性。
类
我们终于讲到了面向对象编程,这是一种非常流行的编程方式,但实际上只是一种你编写代码的一种方式,其他语言诸如C#、Java主要是面向对象的语言。事实上,用这些语言你不能写任何其他类型的代码,虽然你也可以尝试,最终这些语言都是面向对象的语言。
然而C++有点不同,因为它并没有给你强加一种特定风格。例如用C语言不支持面向对象编程,因为为了面向对象编程你需要有一些概念比如类和对象,这些东西C语言没有,而C++会添加所有这些功能,在某种程度上,使用面向对象总是一个好主意。
简单的说,类只是数据和功能组合在一起的一种方法。例如在游戏中我们可能想要一些代表角色的东西,那么我们需要什么样的东西来代表一个角色呢?我们当然需要一些数据,例如角色在游戏中的位置、角色可能拥有的某些属性比如角色移动的速度,我们还可能需要一些3D模型代表角色的屏幕形象,所有这些数据都需要存储在某个地方。
我们可以为所有这些创建变量,假设我们想要创建一个角色比如位置、速度:
|
现在你可能发现这有点乱了,事实上这些名字太普通了,当我们游戏有两个角色时不得不复制然后改成playerX0
、playerX1
等:
|
你当然可以使用数组,但重点还是一样的:它们只是一堆没有组合在一起的变量,它们是无组织的放在我们的代码中,这不是一个好主意。另一个很好的例子可以说明为什么这很烦人,如果我想写一个函数来移动角色,我需要把这三个参数都指定为整数参数:
void Move(int x,int y,int spped){ |
所有这一切变成了如此多的代码,难以维护且非常混乱。所以我们要做的是通过使用类简化它,我们可以创建一个叫做Player
类,它包含了所有我们想要的数据变成一种类型:
class Player{ |
我们通过使用class
然后给它一个名字来实现,这个名字必须是唯一的,因为class
是类型,这里是创建一个新的变量类型。
现在我们又了一个全新的类Player
,本质上它是一种类型,如果我们开始使用Player
类可以把它当作其他变量来创建:
int main(){ |
由类类型构成的变量称为对象,新的对象变量称为实例,在这里我们实例化了一个Player
对象,如果我想设置这些变量可以简单写为player.+x
:
player.x = 5; |
但这里会提示出错,原因是Player
中这些成员变量都是私有的:
这是可见性的原因,当你创建一个新类时你可以选择制定类中的内容的可见性。默认情况下,一个类中所有东西都是私有的,这意味着只有类中的函数才能访问这些变量,然而我们希望能够从main
函数中访问这些变量,所以在这里把它设为public
:意味着我们可以在类之外的任何地方访问这些变量:
|
编译成功,现在我们实现了第一个目标:把所有的变量都放在了一个地方,这些变量集合可以代表一个player
,所以我们把它很好的分组了。
现在我们有了这些数据,假设我们想让player
做一些事情例如移动,需要写一个函数来改变x
和y
变量值,我们该怎么做呢?
|
我们已经写了一个可以移动player
的函数,然而我们可以做的更好一点。类实际上可以包含函数,这意味着我们可以将move
函数移动到类中,类中的函数被称为方法:
|
现在函数在类里面了,我们可以直接得到这些变量而不需要传入player
对象,所有的x
、y
和speed
指的就是当前对象的变量。
我们已经简化了我们的代码,每个Player
对象都有自己的move
函数,当我们为指定的Player
对象调用move
时这个对象将会移动。这与Player
类之外的move
函数没有什么不同,它所做的就是让我们的代码更干净,这样看起来更好看,而当你处理很多代码时,这是一个巨大的优势。
这就是类的基本概念,允许我们将变量分组到一个类型中并为这些变量添加功能。如果你再看一下代码,我们真正做的是我们在一个类类型中定义了三个变量以及一个处理这些变量的函数,这就是我们所做的。类本质上只是语法糖,我们可以使用它来组织我们的代码,使它更容易维护。
类与结构体对比
类和结构体有什么区别呢?上次我们讲到类的时候我们对类有了一些基本的介绍,结构体struct
(structure的缩写)以及类class
看起来有点相似,很多人有点困惑到底区别是什么。
事实是基本没有什么区别,只有一个关于可见度的小区别。一个类的成员默认为private
,这意味着如果我没有加public
之前的代码会报错,告诉我们Player
类的move
方法是不可访问的,因为它被标记为默认的private
只有在类里面的方法才可以访问move
方法。
这就是区别的本质所在,默认情况下类是私有的,所以如果你不指定修改任何可见性,那默认值就是私有的private
。然而在结构体中,默认值却是public
的。技术上讲,这是类与结构体的唯一区别,在代码中体现在把class
换为struct
:
|
从技术上讲,它们可能没有太大的区别,然而实际发生的使用情况会有所不同。struct
结构体在C++中继续存在的唯一原因是因为它希望与C保持向后兼容性,因为C代码没有类但有结构体,如果我们突然去掉整个结构体关键字会失去兼容性,C++编译器不知道什么是struct
。
当然,你可以很容易解决这个问题,只需要用#define
来查找,我们可以写一些类似#define
的东西:
它要做的就是用class
替换所有的struct
,在这种情况下尽管这段代码看起来很简单,但如果我们编译它你会发现同样的编译错误,说move
在类中,不能在外部访问它:
这样做也许我们能得到C与C++的某种兼容性,因为理想情况下你应该能将C语言中的struct
替换成class
,然后将其变成public
。所以语义上的不同以及人们如何看待它,或多或少取决于用法,如果没有区别,那什么时候使用struct
或者类,如果我想要的所有的成员都是公共的,而又不想写public
这个字,我应该使用结构体吗?
是的,它就是那么微不足道,人们都有自己对这两者的理解和定义,没有什么正确或错误的答案,这取决于你的编程风格。让我们谈谈我的编程风格,以及我可能在哪里使用什么类型。
当我讨论Plain old data(POD)时,我喜欢尽可能地使用struct
,只表示变量的结构。这方面的一个很好的例子可能是数学上的向量类:
struct Vec2 { |
不管是class
还是struct
,都是代表这两个浮点数的一种结构,它不应该像之前的Player
类一样包含大量的功能,Player
类可能有一个3D模型,它可能会为这个3D模型处理渲染代码、如何在地图上移动并接受键盘输入……这里有很多功能,而我们的向量只是两个变量,我们把它分组只是为了让我们的代码更容易使用。
当然这不是说我不会在这里添加方法,其实完全可以。我可以添加一个名为add
的方法,它取另一个Vec2
作为参数,然后把它和当前向量相加:
struct Vec2 { |
再次强调,我只是在处理这些变量。也许你会较真的说Player
类不是也只是操纵这些变量吗?其实在设计上还是有一点不同的,因为我们讨论的东西要复杂的多。
另外的场景是继承,我不会在struct
中使用继承。如果我要有一个完整的类层次结构或者某种继承层次结构,我将使用类,因为继承是一种增加另一层次复杂性的东西,我只希望我的结构体是数据的结构,仅此而已。除此之外如果你尝试混合使用这些类型,例如你有一个类a
和一个结构体b
,这个b
继承自a
,某些编译器会报错。
在这里我使用结构体而不是类的原因是如果我只是想用结构体表示一些数据,我将使用一个结构体;但如果我想要一个大量功能的整个类比如一个游戏世界或者一个Player亦或是其他可能需要继承的东西,我将使用一个类,这也是我个人区分这两种类型的方法。
如何写一个C++的类
到目前为止我们学了类,并尝试着从头开始写一个类。今天我们会写一个基本的log
类演示我们已经学过的东西。
让我们来讨论一下我们要写的这个log
类,这个log
类到底是什么?它是我们管理日志信息的一种方式,换句话说我们想要我们的程序打印消息或信息到控制台,这通常用于调试,非常有帮助。在游戏或应用中,如果你想知道发生了什么,你只需要将事物的状态打印到控制台,因为应用程序中的控制台就像一个放信息的地方,我们可以用它来打印出发生了什么,保证代码在正确的工作。
如果我们的游戏中有一个图形要显示在控制台或是一些不同的东西,它可能不会一直起作用,如果我们的图形渲染系统出现了问题我们可能得不到那些信息。然而控制台是操作系统内置的最基本的东西,所以我们可以保证它总是有效的。有些复杂的日志系统有几千行代码,只是为了把东西打印到控制台,但它对调试和开发非常重要,所以花时间在上面是绝对值得的。
log
日志系统不仅可以打印到控制台,也可以用不同的颜色打印,或是通过网络输出日志消息到一个文件,你可以做很多事情。但log
类开始的时候非常简单,它提供向控制台写入文本的能力、保持我们真正想要发送给控制台的日志信息的级别,开始我们有三个级别:
- 错误
- 警告
- 信息
我们能做的是把日志系统级别设置为“警告”,这意味着只会打印警告和错误,而不会打印信息。这很有用,如果你不想看到一堆信息,你只是想知道哪里出了问题或警告是什么。同样通过过滤实际发送和打印的内容,控制台也会很清爽,让我们来看看它会是什么样子:
|
现在让我们思考一下log
类是如何工作的。创建一个类或设计API时,一个很好的方法是通过研究它的使用情况:
|
首先我要实例化它,然后肯定会有一个设置log
级别的SetLevel
方法,意味着只有警告或更重要的信息比如警告或错误才会被打印出来。然后我可能想要打印一个警告符号,里面填点”Hello”这样的。
现在我知道了我的log
类看起来像什么了,我可以直接回去开始填空:
|
其中设置日志级别这里的1很难让人理解,1是什么?因此我们需要创建一个变量,它的值是1,来表示我们想要表示的东西。我们给它起名为LogLevelWarning
,完善三种类型的日志消息常数:
class Log { |
我们有错误Error
,警告Warning
还有信息Information
。默认情况下,我把我的日志级别设置为LogLevelInfo
,意味着所有的东西都应该打印出来。最后我们补充Warn
函数,把东西打印到控制台上,以此类推补充其余两种函数:
class Log { |
这样我们就有了设置日志级别的方法,但目前我们还不能做到如果日志级别设置为warning
就不打印所有的info
信息,我们可以用条件语句来搞定:
#include <iostream> |
|
这里我们打印三条不同的日志信息,但只有warning
和error
被打印出来,info
没有:
因为我们设定的级别为warning
,去掉就会打印三个信息。这个类有很多问题,但它是简单的,而且很有逻辑。当一个人考虑如何编写这个类时,一个经验丰富的程序员是不会这样写的,它给了我一个很好的机会向你们展示如何使用一些不同的概念来改进这个类。
静态
类/结构体外的静态
今天我们将讨论C++中的静态static
。static
关键字在C++中有两个意思,这取决于上下文,其中之一是在类或结构体外部使用static
关键字;另一种是在类或结构体内部使用static
。直白的说,类外面的static
意味着链接将只是在内部,它只能对你定义的翻译单元可见。
然而类或结构体内部的静态变量意味着该变量实际上将与类的所有实例共享内存,在你在类中创建的所有实例中静态变量只有一个实例。类似的事情也适用于类中的静态方法,在类中,没有实例会传递给该方法,在这里我们不深入讨论静态static
在类或结构体范围内的实际含义,今天我们关注在类和结构体外部的静态。
回到代码,在一个新建的static.cpp
,我要做的就是定义一个静态变量:
static int s_variable = 5; |
它和其他变量一样,但在前面是static
关键字。它的意思是这个变量只会在这个翻译单元内部链接,静态变量或函数意味着,当需要将这些函数或变量与实际定义的符号链接时链接器不会在这个翻译单元的作用域之外,寻找那个符号的定义。
最好还是看例子,这里我们创建了一个静态变量s_variable
并将它设为5,然后在另一个C++文件(也就是另一个翻译单元)声明同名的变量,编译不会有任何问题:
|
假如我回到static.cpp
文件删除static
关键字,再次编译运行会得到一个链接错误:
|
这是因为这个s_variable
已经在另一个翻译单元中定义了,所以我们不能有两个同名的全局变量。
一种修改方法是修改这个变量的实际指向,标识这个变量为extern
,这意味着它会在外部翻译单元中寻找s_variable
变量,这被称为external linking
:
|
如果我现在运行代码打印出来的是5,因为它引用了static.cpp
中的s_variable
变量:
然而如果我在static.cpp
标记为静态变量,这有点像在类中声明一个私有变量,其他所有的翻译单元都不能看到这个s_variable
变量。链接器在全局作用域下,将不会看到这个变量,也就是说此时我们尝试编译代码,我们得到一个未解析的外部符号错误,它在任何地方都找不到名称为s_variable
的整型变量:
static int s_variable = 5; |
这是因为我们已经有效地标记了这个变量是私有的。回到static.cpp
,和之前一样声明一个函数Function
:
static int s_variable = 5; |
在main.cpp
我们声明一个同样签名的函数:
#include <iostream> |
此时我们尝试编译,会在链接阶段得到一个重复符号的错误,因为有两个Function
函数:
如果我把static.cpp
标记为静态,当链接器开始链接时它根本不会看到这个静态函数,所以我不会得到任何错误:
static int s_variable = 5; |
这就是C++中静态的全部含义,当你在类和结构体之外使用静态时,它意味着当你声明静态函数或静态变量时它只会在被它声明的C++文件中被“看到”。
如果你想在头文件中声明一个静态变量并将该头文件包含在两个不同的C++文件中,你所做的就是和之前我所做的一样:在两个翻译单元中都声明了相同的s_variable
变量为静态变量,因为当你包含那个头文件时它会复制所有内容并将其粘贴到C++文件中,你所做的是将一个静态变量放到两个不同的翻译单元中。
至于你为什么要用static
,考虑一下为什么要在类中使用private
?如果你不需要变量是全局变量,你就需要尽可能多地使用静态变量,因为一旦你在全局作用域下声明东西的时候,如果没有设定为static
,那么链接器会跨编译单元进行链接,这意味着你已经创建了一个全局的变量,这可能会导致一些非常糟糕的bug。
归根到底,全局变量是不好的,但重点是要让函数和变量标记为静态的,除非你真的需要它们跨翻译单元链接。
类/结构体中的静态
上次我们讨论了C++中的static
关键字以及它在类或结构体之外的意义,今天我们讨论类或结构体中的静态。
如果它在一个类或一个结构体中,static
到底是什么?在几乎所有面向对象的语言中静态在一个类中意味着特殊的东西,如果你把它和变量一起使用,意味着在类的所有实例中,这个变量只有一个实例。如果我创建一个名为Entity
的类,不断创建Entity
实例,我仍然只会得到那个变量的一个版本:意思是如果某个实例改变了这个静态变量,它会在所有实例中反映这个变化,这是因为尽管我已经创建了一大堆类的实例,只有一个变量。
正因如此,通过类实例来引用静态变量是没有意义的,因为这就像类的全局实例。静态方法也是一样,不需要通过类的实例就可以被调用,而在静态方法的内部你不能写引用到类实例的代码,因为你不能引用到类的实例。让我们来看些例子:
|
在这里我写了一个Entity
的结构体,包含两个整数x
、y
(这里我使用结构体,你也可以使用类。这里我选择结构体的原因是希望变量是public
)。然后我们给Entity
结构体一个函数print
打印。
打印e
和e1
,我们应该得到2,3和5,8:
如果我让变量是静态的,事情就会改变。如果我来到这里把x
和y
变成静态的,这里的初始化就会失败,因为x
和y
不再是类成员:
#include <iostream> |
可以看到我们引用了两个不同的实例,此时编译代码会报错,因为我们实际上需要在某个地方定义那些静态变量:
我们可以这样做,先写作用域Entity
,再写变量名x
:
#include <iostream> |
编译代码,你会看到我们实际上打印了两次5,8:
这有点奇怪,因为在代码中第一个实例我们设定了2和3,第二个才是5和8。记住当我们让x
和y
变量为静态时,所有的Entity
实例中只有一个这些变量的实例,这意味当我改变第二个Entity
实例的x
和y
时它们实际上和第一个完全一致,它们指向的是相同的内存,两个不同的Entity
实例它们的x
和y
指向同一个地方。
我们之前这样引用是没啥意义的,可以这样引用它们:
-e1.x = 5; |
这就像我们在名为Entity
的命名空间中创建了两个变量,它们实际上并不属于类。从这个意义上说,它们可以是private
的,也可以是public
的,它们仍然是类的一部分而不是命名空间。但无论出于何种目的,它们其实和在命名空间中一样,当你创建一个新的类的实例或者类似的东西时它们与任何分配无关。
我们重写一下代码:
|
可以看到这些都没啥意义了。这也解释了为什么之前我们得到两个5和8,因为我们实际上在修改相同的变量。
这当然很有用,在你想要跨类使用变量时可以创建一个全局变量,或者不使用全局变量而是使用一个静态全局变量,它是在内部进行链接的,它不会在你的整个项目中是全局的,这样做会有同样的效果,那你为什么要在类中使用静态呢?
答案是把它们放在Entity
中是有意义的,如果你有东西比如一条信息,你想要在所有的Entity
实例之间共享数据或将它实际存储在Entity
类中是有意义的,因为它与Entity
有关。要组织好代码,你最好在这个类中创建一个静态变量而不是一些静态或全局的东西到处乱放。
静态方法的工作方式与此类似。如果我让这个Print
方法变成静态,它会正常工作,因为你可以看到它指向的x
和y
它们也是静态变量,所以我们的调用方式也可以换一下:
-e.Print(); |
这是正确的调用方式。你也可以注意到,它会打印出相同的东西,因为我们运行了两次相同的方法,在这个例子中我们甚至根本不需要类实例:
|
如果我们决定让x
和y
是非静态的,事情就变了。Print
方法仍然保持static
,但静态方法不能访问非静态变量,就是这样简单的原因。有些人可能会对静态的东西能访问什么非静态的东西感到困惑,它真的一点也不令人困惑。
返回我们的Entity
实例重写代码:
|
这样我们实际上对于Entity
类的每个实例都有一个单独的x
和y
,但Print
方法仍然保持静态。此时编译代码,我们会得到一个错误,可以看到是“非法引用非静态成员”:
因为你不能从静态方法访问它,原因是静态方法没有类实例。本质上,你在类中写的每一个非静态方法总是获得当前类的一个实例作为参数,这是类在幕后的实际工作方式。在类中你看不到这种东西,它们通过隐藏参数发挥作用,静态方法不会得到那个隐藏参数。
静态方法与在类外部编写方法相同,如果我在外面写一个Print
方法,你就知道为什么不能访问x
和y
了:因为你不知道x
、y
是啥:
struct Entity { |
想象你有同样的Print
方法,但有一个Entity
对象作为参数传入。这个方法本质上是非静态类方法在编译时的真实样子,而去掉参数Entity e
正是我们将static
关键字添加到类方法时所做的,这就是为什么会报错:它不知道你想要访问哪个Entity
的x
和y
,因为你没有给它一个Entity
的引用。
static
对于那些静态数据非常有用,这些数据不会在类实例之间发生变化,但实际上我们想要在类中使用它们。
局部静态
在前面几节我们了解了static
关键字在特定上下文中的含义,今天我们看一看局部作用域中的static
关键字。你可以在局部作用域中使用static
来声明一个变量,这和我们之前看到的两种static
有点不同,这次的局部静态(local static)会有更多的含义,声明一个变量需要考虑两种情况:变量的生存期和变量的作用域。
生存期指的是变量实际存在的时间,换句话说就是在它被删除之前,它会在我们的内存中存在多久。而变量的作用域是指我们可以访问变量的范围,当然,如果在一个函数内部声明一个变量我们不能在其他函数中访问它,因为我们声明的变量对于我们声明的函数是局部的。
静态局部(local static)变量允许我们声明一个变量,它的生存期基本上相当于整个程序的生存期,然而它的作用域被限制在这个函数内,但它其实和函数没有什么关系,我的意思是你可以在任何作用域中声明这个,刚才只是用函数举个例子,这并不仅仅局限在函数内部,也可以在if
语句中,也可以在任何位置。
这就是为什么函数作用域中的static
和类作用域中的static
之间没有太大的区别,因为生存期实际上是相同的,唯一的区别是在类作用域中,类的任何东西都可以访问它(这个静态变量);如果你在函数作用域中声明一个静态变量,那么它将是那个函数的局部变量,对类来说也是局部变量。
让我们来看一些例子。最简单的例子就是创建一个函数,然后在其中声明一些静态变量:
|
这意味着当我第一次调用函数时,这个变量将被初始化为0,然后所有对函数的后续调用实际上不会创建一个全新的变量。
检验这个最简单的方法是如果我打印i
的值,然后每次调用函数时都增加i
的值。如果我们暂时把static
关键字去掉然后运行程序:
|
运行程序,1被打印了五次。如果我们把static
关键字加上,再次运行程序:
void Function() { |
这种写法也可以写成下面这样(无论i
静态与否),得到的效果是一样的:
|
但这种方法的问题是我可以在任意地方访问i
,这极大的改变了我们程序所做的事情。所以如果你想要做这些,但又不希望每个人都能访问这个变量,你可以在局部作用域下声明成static
。
有些人不赞成使用这种方法,我不完全理解其中的原因,因为我不认为这有什么问题。它确实有它的用处,你可以使用其他方法实现完全相同的行为,比如可以使用类来实现,但你根本不必使用类来编写程序,局部静态确实让编程更轻松。
另一个例子是如果你有一个单例类(只存在一个实例的类),如果我想创建这个单例类而不使用静态局部作用域,我就需要创建静态的单例实例:
class Singleton { |
现在我有了单例类,我可以调用Singleton::Get
对它做任何我想做的事情:
|
一切搞定,我们得到了一个可以使用的类实例,你可以用静态方式来使用它,你不一定要这么做。
另一种方式是使用我们的新知识局部静态local static:
class Singleton { |
我们得到了完全相同的行为,运行代码没有问题,你可以看到我们的代码现在干净多了。
枚举
今天我们要讲的是C++的枚举。enum
是enumeration的缩写,基本上它就是一个数据集合。如果你想要给枚举一个更实际的定义,他们是给一个值命名的一种方法,所以我们不用一堆叫做a
、b
、c
的整数,我们可以有一个枚举数,它的值是a
、b
、c
与整数对应。
它能帮助我们将一组数值集合作为类型,而不仅仅是用整型作为类型。当然,你可以给它赋值任何整数或者限制哪些值可以赋值。它只是一种命名值的方法,当你想要使用整数来表示某些状态或者某些数值时,它非常有用。不管怎么说,枚举数其实就是一个整数。
假设我有三个值想要处理,可能有一些代码来检查当前的value
值是什么,然后执行某种操作:
|
然而这段代码带来了一些问题。首先这些A
、B
、C
根本没有分组,在代码后面的某个地方你可能会有一个D
或者你可能想再次声明A
,本质上最大的问题是这些根本没有分组。此外,它们只是整数,这意味着如果int value = 5
下面的这些已经没有任何意义了。
我们希望本质上定义一个类型,只能是这三个数中的一种,而且能够把它们组合起来,这正是我们可以使用枚举的地方:
|
如果你不按照默认来搞,你也可以指定这些变量的值。默认的第一个是0,然后它一个接一个地递增:
enum Example { |
如果你从一个非零的数字开始比如5,并且并没有指定其余的值,它会默认是6和7。我们还可以做的一件事是,指定你想要给枚举赋值的整数类型,你可以写一个冒号后接数据类型,例如unsigned char
:
enum Example : unsigned char { |
枚举默认为32位整型,然而在这个例子中我们没有必要使用32位,我们可以使用8位的整数比如unsigned char
减少内存的使用。你不能使用float
,因为它不是整数,枚举必须是一个整数比如char
。
这就是枚举的本质,它只是给特定的值命名的一种方式,这样你就不必在各种地方处理各种整数。让我们把枚举用在之前的日志里:
|
我们在这里使用了三个不同的Log
级别,而它们只是整数0、1、2,这是一个非常适合使用枚举的地方,因为我们有三个值,用它们作为整数来表示某个状态。这个例子中日志级别的意思是指会展示哪种级别的日志:
class Log { |
注意这个枚举Level
本身不是一个命名空间,这叫做枚举类。然而对于普通的枚举而言,这个Level
并不是真正的命名空间,所以你不能把它当作一个命名空间,这意味着ERROR
、WARNING
和INFO
只存在于这个日志类中。
构造函数
今天我们继续学习C++面向对象编程,包括像类、构造函数所有这些东西。那什么是构造函数呢?构造函数基本上是一种特殊类型的方法,它在每次实例化对象时运行,让我们来看个例子。
初始化
假设我们想要创建一个Entity
类并在主函数中实例化打印:
|
这里得到Entity
的位置,看似是随机的值。这是因为我们实例化Entity
并为它分配内存时,我们实际上并没有初始化那个内存,这意味着我们得到了那个内存空间里原来的那些东西,我们可能想做的是初始化内存将其设置为0之类的,这样位置就默认为0。
我们已经知道需要某种初始化,需要一种方法当构造Entity
实例时把x
和y
设为零。我们来创建一个叫Init()
的方法,它将设置两者的值:
#include <iostream> |
构造函数
我们额外编写了相当多的代码,需要定义Init()
方法。每当我们想在代码中创建一个Entity
对象(实例)都意味着我们需要实际运行Init()
函数,相当多的代码代表着相当地不清爽。
当我们构造对象时,如果我们有办法直接运行这个初始化代码就好了,于是就有了构造函数。
构造函数时一种特殊类型的方法,每一次你构造一个对象都会调用这个方法。我们像定义其他方法一样定义它,然而它没有返回类型,并且它的名字必须与类的名称相同:
#include <iostream> |
运行代码可以看到我们得到的结果和Init()
方法得到的结果是一样的,现在初始化函数可以被构造函数替代了。
如果不指定构造函数,你仍然有一个构造函数,它是一个叫默认构造函数的东西,默认情况下它已经为你准备好了。然而这个构造函数实际上什么都没做,它基本等于:
Entity() { |
像Java等语言中数据基本类型(如int
和float
)会自动初始化为0,但C++的情况并非如此,你必须手动初始化所有基本类型,否则它们将被设置为留在该内存中的其他值。所以初始化非常重要,一定不要忘记。
有参构造函数
我们来看一下带参数的构造函数,可以写很多个构造函数,前提是它们有不同的参数。这叫做函数重载,即有相同的函数(方法)名,但是有不同的参数的不同函数版本。
Entity(float x,float y) { |
我们把x
和y
赋值,把参数赋值给了我们的成员变量。现在我可以选择使用参数来构造Entity
对象了:
#include <iostream> |
如果不实例化对象,构造函数将不会运行,所以如果你只使用一个类的静态方法,它不会运行。当然,当使用new
关键字创建一个对象实例时,它也会调用构造函数。
这里也有一些方法可以删除构造函数,例如你有一个Log
类,它只有静态的日志方法:
class Log { |
我只是想让人们像这样使用我的Log
类,不希望人们创建实例。有两种不同的解决方法,我们可以通过设置为private
来隐藏构造函数:
class Log { |
可以看到这里得到一个错误,因为我不能访问构造函数。C++为我们提供了一个默认的构造函数,然而我们可以告诉编译器:“不,我不想要那个默认构造函数”:
class Log { |
我们同样不能调用Log
,因为默认构造函数实际上并不存在,已经被删除掉了。
还有一些特殊类型的构造函数比如赋值构造函数还有移动构造函数,之后我们会讲。对于基本的使用,这就是构造函数:一个特殊的方法,当你创建类的实例时运行。它的主要用途是初始化该类,当你创建一个新的对象实例时,构造函数确保你初始化了所有的内存,做所有你需要做的设置。
成员初始化列表
今天我要讲的是构造函数初始化列表,这是我们在构造函数中初始化类成员(变量)一种方式。因此当我们编写一个类并向该类添加成员时,通常需要用某种方式对这些成员变量进行初始化。这通常在构造函数中,有两种方法,我们可以在构造函数中初始化一个类成员,让我们来看一看。
我们有一个 Entity
类,它只有 name
成员变量:
class Entity { |
这可能是你之前一直在做的方式,但 C++ 中实际上还有另外一种方法:成员初始化列表。与上面设置 name
不同,在构造函数和参数之后我们可以添加一个冒号,然后开始列出你想要初始化的成员:
#include <iostream> |
这就是成员初始化列表的方式。如果我们有另一个成员比如 score
,只需要加一个逗号然后写上这个成员,在这种情况下我把它初始化为0:
class Entity { |
需要注意的是如果你定义这些变量,那么你在成员初始化列表中要按照顺序写,否则有些编译器会警告你。这很重要,因为不管你怎么写初始化列表,它都会按照定义类成员的顺序进行初始化。
在这个例子中,首先初始化整数 score
,然后初始化字符串 name
。即便你在初始化列表的时候,用另一种方式来初始化列表,比如先初始化字符串再初始化整数,会导致各种各样的依赖性问题,所以你要确保你做成员初始化列表时,要与成员变量声明时的顺序一致。
最大的问题是为什么我们要使用这个成员初始化列表,只是代码风格的问题吗?答案是对,又不对,不对可能更加正确。我喜欢这样写代码,因为如果你有很多成员变量,在函数体内初始化它们会变得非常凌乱,你的构造函数大部分内容都只是在初始化变量,都是些琐碎无聊的事情,可能很难看出构造函数到底在做什么:
class Entity { |
你会想隐藏它们,这就是为什么我喜欢把它们放在成员初始化列表中。即使从代码风格的角度来看,我也更喜欢这样,它让我的构造函数非常干净,易于阅读:
class Entity { |
但实际上在特定的类的情况下有一个功能上的区别,如果我们稍微修改一下去掉成员初始化列表中的赋值:
class Entity { |
实际上会发生的是这个 name
对象会被构造两次,一次是使用默认构造函数,然后是这个用 UNKOWN
参数初始化,实际上发生的是这样:
name = std::string("UNKOWN"); |
所以你创建了2个字符串,其中一个被直接扔掉了。这是对性能的浪费,让我们演示一下。我要在这里创建一个 Example
类,有两个 public
构造函数:
#include <iostream> |
这里我所做的只是创建一个 Entity
对象的实例,使用这个默认构造函数。运行程序:
我们创建了两个 Entity
,一个是无参的,一个是有整型参数的。这两个实际产生的 Entity
一个是源自这里:
class Entity { |
另一个我们在这里创建了一个新的 Example
实例,然后把它赋值给 example
:
class Entity { |
但是我们刚刚创建了一个 Example
类实例,相当于我们又扔掉它,用一个新的对象覆盖它。然而如果我们把它移到初始化列表中:
class Entity { |
我们运行构造函数,只是创建了一个实例。甚至我可以把这个删掉,直接传入参数,你可以看到是完全一样的:
class Entity { |
这就是区别。你应该到处使用成员初始化列表,相反绝对没有理由不使用它们。如果你不喜欢这种代码风格,要习惯它们,因为这不仅仅是风格的问题,实际上有一个功能上的区别,如果不使用它们就会浪费性能。
当然,并非所有情况都是如此。对于整数这样的基本类型,它不会被初始化,除非你通过赋值来初始化它们,但我不会区分原始类型和类类型,全部使用成员初始化列表。
创建并初始化对象
今天讲讨论如何用 C++ 创建对象。C++ 给了我们一些创建对象的方法,当我们写完一个类,就该开始使用我们创建的类了,通常我们需要实例化它,除非它是完全静态的,但我们不讨论这个。
我们需要实例化一个类,该怎么做呢?我们基本上有两个选择,这两个选择之间的区别是内存是从哪里来的、我们在哪里创建对象。当我们创建一个 C++ 对象时,它需要占用一些内存,即使我们写一个完全为空的类,类中没有成员,什么都没有,它也至少要占用一个字节的内存。
但同情况并非如此,我们的类中有很多成员,它们需要存储在某地方。当我们决定开始使用这个对象时,我们会创建一堆变量,对象有一堆变量,我们需要在电脑的某个地方分配内存,这样我们就可以记住这些变量设置的值。
应用程序会将内存主要分为两部分:堆和栈。还有其他部分的内存如源码的区域,这些都是机器代码,它们都很重要,但现在我们就考虑堆和栈。在 C++ 中我们要选择对象要放在哪里,对象是在栈上还是堆上创建,它们有不同的功能差异。比如栈对象有一个自动的生存周期,它们的生存周期实际上是由它声明的地方作用域决定的,只要变量超出作用域,也就是说内存被释放了,因为当作用域结束的时候,栈会弹出作用域里的东西,栈上的任何东西会被释放。
但堆不同的,堆是个很大的神秘的地方。一旦在堆中分配一个对象,实际上你已经在堆上创建了一个对象,它会一直待在那里直到你做出决定:“我不需要它了,我想释放这个对象”,你想怎么处置那段内存都行。
让我们看看这两种创建对象的方法的代码是什么样子的:
|
我有一个叫 Entity
的类,有一个成员字符串 name
。然后我们有一个构造函数,它不接受任何参数,另一个构造函数它接受一个字符串 name
作为参数。最后我们还有一个简单的 getName()
方法,这就是这个简单类的组成。
现在让我们在 main
函数中在栈上创建它,键入实例化的类的类型:
int main(){ |
这样写实际上调用了默认构造函数。如果你来自 C# 和 Java 语言,这个代码可能看起来有些奇怪,事实上这可能会导致所谓的空指针异常或空引用异常,因为它看起来就像我们根本没有初始化对象,但是实际上我们可以,因为我们有默认构造函数,这代码完全 OK。
现在我们可以调用 entity.getName()
,这种情况下我们会得到 “UNKNOWN”:
int main(){ |
如果我们想要指定一个参数,我们只需要给一个参数调用另一个构造函数:
int main(){ |
那么我们什么时候这样创建对象?答案是几乎所有的时候,如果你能像这样创建对象,那就像这样创建对象,这是基本的规则。因为这是 C++ 中最快的方法,也是可以“管控”的方法去初始化对象。
现在的问题是,某些情况下你不能这么做。其中一个原因是如果你想把它放到这个函数生存期之外比如另一个函数 Function()
:
void Function() { |
一旦我们到达这个花括号,这个 entity
会从内存中被销毁。因为当我们调用 Function()
时就为这个函数创建了一个栈结构,它包含了我们声明的所有局部变量,包括基本类型和我们的类和对象。当这个函数结束时,栈会被销毁,也就是说栈上所有的内存所有创建的变量都消失了,因此我们写一些会失败的代码:
int main(){ |
这个叫 YOUSAZOE
的 entity
已经不存在了,我们到达栈的末端,这就是 YOUSAZOE
的末日。因此,如果我们想让这个 YOUSAZOE
在作用域之外依然存在,就不能分配到栈上,我们讲不得不求助于堆分配。
另一个我们不想分配到栈的原因是,如果这个 entity
的规模太大,而且我们可能有太多的 entity
,我们可能没有足够的空间在栈上分配,因为栈通常非常小,通常1兆或2兆,这取决于你的平台和编译器。但如果你有这么大的类或你想有一千个这样的类(对象),栈上可能没有足够的空间,因此可能必须在堆上进行分配。
让我们来看看堆分配:
int main(){ |
如果我们想把这代码转换成在堆上分配,我们首先要做的是改变类型为 Entity*
,然后我们使用 new
关键字。这里最大的区别不是那个类型变成了指针,而是这个 new
关键字。
当我们调用 new Entity()
时,我们会在堆上分配内存,调用构造函数返回一个 Entity*
,而这也是 Java 和 C# 语言的码农做的事情。你不应该到处使用 new
关键字,简单来说就是性能问题,在堆上分配要比栈花费更长的时间,而且在堆上分配的话你必须手动释放被分配的内存。
这就是我们创造对象的两种方法,如何选择呢?如果对象太大或你要显式地控制对象的生存周期,那么就在堆上创建;否则就在栈上分配吧,栈上创建简单多了,自动化而且更快。
new关键字
这个 new
关键词很有趣,因为它实际上很深奥。这里有很多人他们不会去想这个问题,但还是有很多东西非常重要,需要理解,特别是你用 C++ 来编程。
事实上你在编写 C++ 程序时,你应该关心内存、性能和优化等问题,因为如果你不关心这些,那你为什么要用 C++ 呢?有很多其他的语言你可以用,尤其是现在2021年,你为什么要写 C++?除非你特别需要性能或者你要掌握一切。
那你就要知道,new
关键字是非常非常重要的,特别是如果你来自 Java 或 C# 这样的托管语言,内存会自动清理。但在内存方面,你也没那么多控制能力,这可不像是在 C++ 中。所以对于那些懂 Java 或 C# 的人,你总是习惯用 new
,那你用 C++ 的时候可能会想:“C++ 也不是最困难的嘛”。
new
的主要目的是在堆上分配内存。你写了 new
然后写上数据类型,根据你所写,不管它是一个类,还是一个基本类型,还是一个数组,它决定了必要的分配大小,以字节为单位,例如通过写一个 new int
,它需要4个字节的内存。一旦它有了它要处理的数字,它会询问你的操作系统:“我需要四个字节的内存,请把它给我”。
这就是乐趣的开始,现在我们需要找到一个包含四个字节内存的连续块,当然四个字节的内存很容易找到,所以分配起来也很快,但它仍然需要在一行内存中找到4个字节的地址空间。一旦这样找到之后,它会返回一个指向这个内存的指针,这样你就可以开始使用你的数据并在那里存储数据,进行读写访问,做所有这些有趣的事情。
当你调用 new
时,将消耗时间。我说过,我们必须寻找四个字节的连续内存,但这并不是搜索内存就像激光扫过一行那样,而是有一种叫做空闲列表的东西,它会维护那些有空闲字节的地址。这是主要的方法,new
就是找到一个足够大的内存块已满足我们的需求,然后给我们一个指向那块内存的指针。
让我们看一些代码:
|
这里我有一个非常基本的类,只有一个 name
字符串,之前几次课也用过。
就像我们平常创建整数那样,我们也可以选择动态分配内存,并通过 new
关键字在堆上创建:
int main(){ |
一个单一的四字节在堆上分配,这个 b
存储的是它的内存地址。如果我想分配一个数组,那就价格方括号,然后输入我想要多少元素:
int* b = new int[50]; |
这个例子中是50,也就是我们需要200字节内存。如果我们想在堆上分配我们的 Entity
类,可以通过新的关键字 new
:
Entity* e0 = new Entity; |
new
关键字不仅分配内存,还调用构造函数。查看它的定义就会知道,new
是一个操作符就像加减乘除一样,这意味着你可以重载这个操作符,并改变它的行为,之后我们会讲到操作符重载。
其次 new
返回的是空指针,它是一个没有类型的指针。指针指示一个内存地址,指针之所以需要类型,是因为你需要类型来操纵它。
不仅如此,通常调用 new
会调用里面隐藏的 c 函数 malloc()
,它代表内存分配以及它实际作用,传入一个 size
也就是我们想要多少字节,然后返回一个 void
指针:
malloc(50); |
这代码实际上相当于我们写了:
Entity* e = Entity(); |
这两行代码之间的仅有区别是前者调用了 Entity
构造函数,而后者做的仅仅是分配内存,然后给我们一个指向那个内存的指针,没有调用构造函数。
你不应该在 C++ 中这样分配内存,在某些情况下你可能希望这样做,我们以后再谈,但是现在对你来说还是用 new
吧。
关于 new
最后一点是当你使用 new
关键字时必须要使用 delete
。一旦我们分配了所有这些变量比如 b
或 e
,我们必须使用 delete
关键字(它也是一个操作符)。它是一个常规函数,它申请调用了 C 语言的 free()
释放 malloc()
申请的内存。
这一点很重要,因为当我们使用 new
关键字时内存未释放,它不会被放回空闲列表,所以就不能被 new
调用后再分配直到我们调用 delete
,我们必须手动操作。当然,很多 C++ 的策略可以让这个过程自动化。有简单的策略,比如基于作用域的指针;也有一些高级策略,比如引用计数。
析构函数
上次我们讨论了构造函数,包括它们是什么以及如何使用它们。今天我们要讨论一下它邪恶的孪生兄弟,析构函数,它们很相似:
- 构造函数是你创建一个新的实例对象时运行;而析构函数时在销毁对象时运行,所以任何时候,一个对象要被销毁时将调用析构函数
- 构造函数通常是设置变量或者做任何你需要的初始化;同样的,析构函数是你写在变量等东西,并清理你使用过的内存
- 析构函数同时适用于栈和堆分配的对象。如果你使用
new
分配一个对象,当你调用delete
时析构函数将会被调用;而如果只是一个栈对象,当作用域结束时栈对象将被删除,这时析构函数也会被调用
让我们为之前的Entity
类添加一个析构函数:
~Entity() { |
构造函数和析构函数在声明与定义时唯一区别,就是放在析构函数前面的波浪号,有了这个符号~
,你就知道这是析构了。
在这个例子中我们只有一个简单的类,有两个成员x
和y
。当我们为这两个浮点变量申请内存的时候,完全没有考虑之后怎么清除内存,我们会在之后讨论内存分配等复杂问题。
我们继续添加两条消息,告诉我们对象已经被创建或删除:
class Entity { |
因为这是栈分配的,只有当主函数退出时析构函数才会被调用,所以我们实际上不会看到,因为我们的程序会在那之后立即结束。所以我要写一个Function()
函数,它将执行Entity
的相关操作:
+void Function() { |
让我们更深入地看看它是如何工作的。我在25行放一个断点开始调试:
可以看到现在控制台啥都没有,继续走下去:
我们看到Entity
被创建,构造函数被调用的结果。接着走下去:
这里调用Print()
打印两个成员变量数值。再往下走:
最后作用域到此结束,我们要跳回30行:我们函数返回的地方。因为它的对象是在栈上创建的,当超出作用域时它会被自动销毁,也就是对象e
,同时析构函数被调用。
这就是析构函数的本质,它只是一个特殊函数或方法,在对象被销毁时调用。为什么我们要写析构函数呢?因为如果在构造函数中调用了特定的初始化代码,你可能想要在析构函数中卸载或销毁所有这些东西,否则可能会造成内存泄漏。
这个方面一个很好的例子是在堆上分配的对象,如果你已经在堆上手动分配了任何类型的内存,那么你需要手动清理。如果在Entity
类使用中或构造中分配了内存,你可能要在析构函数中删除内存,因为当析构函数调用时那个实例对象消失了。
继承
面向对象编程是一个巨大的编程范式,类之间的继承是它的一个基本方面,它是我们可以实际利用的最强大的特性之一。继承运行我们有一个相互关联的类的层次结构,换句话说它允许我们有一个包含公共功能的基类,然后它允许我们从那个基类中分离出来,从最初的父类中创建子类。
继承如此有用的主要原因是它可以帮助我们避免代码重复。代码重复是指我们必须多次写完全相同的代码或者只是可能会略有不同,本质是完全一样的东西。我们不需要一遍又一遍地重复自己,我们可以把类之间的所有公共功能放在一个父类中,然后从基类(父类)创建(派生)一些类,稍微改变下功能或者引入全新的功能。继承给我们提供了这样一种方法,将这些公共代码放到基类中,这样我们就不用像写模版那样不断重复了,让我们来看看如何定义它。
假设我有一个Entity
类,它将管理游戏中所有的实体对象。在游戏中我们有很多非常具体的实体,然而在某些方面它们将共享功能。例如每个实体在游戏中都有自己的位置,这可以通过两个float
数来表达:
|
我们可能想赋予每个实体移动的能力,可通过move()
方法,将移动位置作为参数:
void move(float dx,float dy) { |
现在我们有了一个基类Entity
,在游戏中创建的每一个实体都将具有这些特征。让我们继续创建一个新类型的实体,例如一个Player
类:
class Player { |
此时还没有继承。如果我们从零开始,我们希望它也有位置以及移动,和Entity
非常相似。也许这个Player
类有我们想要额外存储的数据,例如名字name
。
这实际上是不同的类了,然而有相当多的代码只是被复制粘帖。所以我们能做的就是利用继承的力量,扩展这个Entity
实体类来创建一个名为Player
的新类型,然后让它存储新数据以及提供额外的功能,例如打印名字:
class Player { |
现在让我们把Player
变成Entity
的子类。我们在类型声明后面写一个冒号:
,然后我们写public Entity
:
+class Player : public Entity{ |
现在我们写的这行代码中发生了一些事情,Player
类现在不仅拥有Player
类型,而且它也有Entity
类型,意思是现在是两种类型了。
类型在C++中是相当复杂的主题,因为一方面它们实际上并不存在,然而另一方面它们又会搞事,特别是你有特定的运行时标记激活的话,那个时候我们再去深入了解这整个东西时如何工作的。
Player
现在拥有Entity
拥有的所有东西,所以我们拥有所有的类成员x
、y
,让我们把重复的代码都去掉:
class Player : public Entity{ |
这样Player
类看起来很干净了,然而它实际上是一个Entity
,这意味着仅仅看这个类并不能告诉我们整个故事,我们必须去找Entity
看看它有什么。就Player
而言任何Entity
类中不是私有的东西都可以被访问。
假设我有一个Player
实例player
,我不仅可以调用printName()
函数,也可以调用move()
函数并访问x
、y
:
int main(){ |
我可以访问 x
和 y
就像它是一个 Entity
一样,因为它继承了所有的 Entity
的功能。还有一个概念叫做多态,在以后会深入讨论,多态是一个单一类型,但有多个类型的意思。Player
不只是 Player
类型,而且也是一个 Entity
,这意味着我们可以在任何我们想要使用 Entity
的地方使用 Player
,因为 Player
总会拥有 Entity
所拥有的一切再多加一点点东西。
如果我想创建一个打印 Entity
对象的独立功能,例如通过访问 x
和 y
变量并将它们打印到控制台上,我可以传入 Player
对象到相同的函数中,即使这个函数是接受 Entity
作为参数的。可以这样搞的原因是 Player
保证会有这些 x
和 y
变量,包含所有 Entity
的东西。
继承是我们一直使用的一种方式,它是一种扩展现有类并为基类提供新功能的方式,这是面向对象编程中最重要的东西之一,当你创建一个子类时,它将包含你的父类所包含的一切。
另一个证明方法是打印内存的大小:
#include <iostream> |
这就是继承在C++类中如何工作的要点。
虚函数
今天我们来聊聊C++中的虚函数。之前的章节我们一直在讨论类、面向对象编程、继承,所有这些东西包括今天的虚函数,对整个面向对象概念都非常非常重要。
虚函数允许我们在子类中重写方法,所以假设我们有两个类 A
和 B
,B
是 A
派生出来的,也就是 B
是 A
的子类。如果我们在 A
类中创建一个方法,标记为 virtual
,我们可以选择在 B
类中重写那个方法,让它做其他事情。
virtual
像往常一样,通过一个例子可以很好地解释这一点:
|
我们创建了两个类,一个是 Entity
是我们的基类,唯一拥有的是一个名为 getName()
的公共方法,它会返回一个字符串 "Entity"
;另一个类是 Player
,它将是 Entity
类的子类,在这个类里我们存储一个名字字符串 name
、提供一个构造函数 Player()
,我们给它一个叫 getName()
的方法,返回 name
。
让我们看看如何使用这些设定。假设我们在这里创造了一个 Entity
,我要试着打印 getName()
,Player
同理:
int main(){ |
酷,看起来不错,我们得到了打印结果。然而如果我们使用多态的概念,那么到目前为止我们在这里编写的所有内容都有问题了,如果我们指向一个 Player
,就像它是一个 Entity
一样,我们就会遇到问题。
如果我在这里创建一个名为 entity
的变量,它会被赋值为 p
指向 Player
的指针,此时调用打印函数:
int main(){ |
运行代码,得到结果为 Entity
,然而我们希望是 Player
,它是一个 Player
实例。
一个更好的例子是用一个 printName()
替换:
+void printName(Entity* entity){ |
我们期望不同的 getName()
函数作用于不同的类对象。然而如果我们运行代码,Entity
打印了两次,为什么会这样?
发生这种情况的原因是在我们声明函数时,方法通常在类内部起作用,然后当要调用方法的时候会调用属于该类型的方法,而这里参数为 Entity*
决定了它会从 Entity
类中找到这个 getName()
函数:
void printName(Entity* entity){ |
我们希望C++能意识到这一点,这就是虚函数出现的地方。
虚函数引入了一种叫做动态联编(Dynamic Dispatch)的东西,它通常通过虚函数表来实现编译。虚函数表就是一个表,它包含基类中所有虚函数的映射,这样我们可以在它运行时将它们映射到正确的覆写(override)函数,之后为会做一个关于虚函数表如何运作的深度文章,但为了简单起见,你只需要知道如果想覆写一个函数,必须将基类中的基函数标记为虚函数。
回到我们的代码,在 Entity
类中 getName()
方法前面加上 virtual
这个词:
class Entity { |
尽管没有做很多工作,但这可以告诉编译器:“嘿,生成虚函数表吧!”。运行代码,我们得到了正确的结果:
override
在C++11中我们可以做的另一件事情是将覆写函数标记为关键字 override
:
class Player : public Entity { |
这不是必须的,你可以看到刚刚我们没写那个 override
,它也工作得很好。然而你还是应该这样做,因为首先这让它更具可读性,现在我们知道这实际上是一个覆写函数,而且它还可以帮助我们预防 bug 的发生比如拼写错误之类的。
这就是虚函数的本质,但是很遗憾,虚函数并不是免费(无额外开销)的,有两种与虚函数相关的运行时成本:
- 首先我们需要额外的内存来存储虚函数表,这样我们才可以分配到正确的函数,包括基类中要有一个成员指针指向虚函数表
- 其次我们调用虚函数时,我们需要遍历这个表来确定映射到哪个函数,这是额外的性能损失
由于这些成本,有些人根本就不喜欢使用虚函数。老实说,根据我的经验,我没有遇到开销特别大的情况,所以我个人而言经常用,没有任何问题,可能在一些嵌入式平台上 cpu 性能非常差需要注意避免使用虚函数。
接口
今天我们讲的是一种特殊类型的虚函数:纯虚函数。
C++纯虚函数本质上与其他语言(如Java或C#)中的抽象方法或接口相同。基本上纯虚函数允许我们在基类中定义一个没有实现的函数,然后强制子类去实现该函数。如果我们看一下之前的例子,可以看到我们在 Entity
类中有一个虚函数 getName()
,然后我们在 Player
中重写了那个函数:
class Entity { |
在这个基类中 getName()
有函数体,意味着在某个类中重写它只是一个可选项,即使我们不重写它仍然可以调用 Player.getName()
返回字符串 Entity
。然而在某些情况下,提供这种默认实现是没有意义的,实际上我们可能想要强制子类为特定的函数提供自己的定义。
在面向对象编程中创建一个只由未实现的方法组成,然后强制子类去实现它们非常常见,这通常被称为接口。因此,类中的接口只包含未实现的方法作为模板,由于这个接口类实际上并不包含方法实现,我们实际上不可能实例化那个类。让我们看看这个在 Entity
类中的 getName()
函数能不能搞成纯虚函数:
class Entity { |
我们去掉了函数体就写成等于0。注意,这里依然是定义成 virtual
虚函数,但等于0本质上使它成为一个纯虚函数,这意味着如果你想实例化这个子类,它必须在一个子类中实现。
这样做确实发生了一些变化,在 main()
函数中我们不再具有实例化 Entity
类的实例,我们必须给它一个子类来实现这个函数:
目前 Player
工作正常,因为我们实现了那个 getName()
函数。如果我注销掉这个实现,你可以看到 Player
也不能进行实例化了:
class Player : public Entity { |
本质上,你只能在实现了所有这些纯虚函数之后,才能够实例化。或者实现在更上层的类也是可以的,比如 Player
类是另一个类(Entity
的子类)的子类,而这个类实现了 getName()
函数。我们的想法是,纯虚函数必须被实现,才能创建这个类的实例。
好了,让我们看一个更好的例子。先把我们之前的操作撤销,假设我们想要编写一个函数打印这些类的类名,我们需要一个类型可以提供 getClassName()
函数:
void Print(Printable obj){ |
让我们把这个叫做 Printable
,然后设置它。创建一个新类 Printable
,它唯一会有的是一个纯虚字符串函数:
#include <iostream> |
然后我要让 Entity
实现那个接口。注意虽然它叫做接口,但它其实只是一个类,所以还是 class
而不是 interface
(其他语言有这个关键字,但C++没有,接口只是C++的类而已)。
现在所有类都需要实现这个 getClassName()
函数了,否则我们将不能实例化这个类:
#include <iostream> |
现在可以看到,我得到了正确的类名,所有这些都来自于一个 Print()
函数,这个函数接受 Printable
作为参数。如果你不实现这个函数,你就不能实例化这个类。
可见性
今天我们讨论 C++ 中的可见性。
可见性是一个属于面向对象编程的概念,它指的是类的某些成员或方法实际上有多可见。我说的可见性是指:谁能看到它们、谁能调用它们、谁能使用它们这些东西,所以一开始我要提一下,可见性是对程序实际运行方式完全没有影响的东西,对性能也没有影响,它纯粹是语言中存在的东西,让你能够写出更好的代码或者帮助你组织代码。
C++ 中有三个基础的可见性修饰符:private
、protected
和 public
。在其他语言比如 Java 和 C# 有其他关键字,比如 Java 中你可以不使用可见性修饰符,这就是所谓的 default
可见性修饰符;在 C# 中有个可见性修饰符叫做 internal
。
在 C++ 中我们就是三个可见性修饰符:private
、protected
和 public
。让我们来看看它们在类中是怎么做的:
class Entity { |
如果我在一个 Entity
中把 x
和 y
定义为两个变量,默认的可见性是私有的,也就是说这段代码和我写 private
完全一样:
class Entity { |
但是如果这里不是 class
而是 struct
,那么它将默认为公开的:
struct Entity { |
private
让我们回到类,把这些设为私有,什么是 private
?private
意味着只有(Only*)这个 Entity
类可以访问这些变量,它可以读取和写入它们。这里的 Only* 要给个星号*,因为在 C++ 中有个叫 friend
的东西。它是 C++ 中的关键字,可以让类或者函数成为类 Entity
的朋友(友元),friend
的意思是友元,实际上可以从类中访问私有成员。
回到代码,如果我此时要在主函数里实例化这个 Entity
,在这个类的作用域之外我不能调用 x = 2
或类似的东西,因为它是私有的:
|
如果有一个 Entity
的子类为 Player
,这里依然不能访问 x
,只有 Entity
类和它的友元才能访问这些变量:
|
这同样适用于函数,这里我们新建一个 Print()
函数,可以从 Entity
类中调用函数,这完全没有问题。然而当我试图从子类 Player
或者一个完全不同的地方,实际上我不能调用它,因为是私有的:
#include <iostream> |
protected
我们有个东西叫 protected
,它比 private
更可见,但比 public
更不可见。protected
意思是这个 Entity
类和层次结构中的所有子类也可以访问这些符号:
|
可以看到现在我完全可以在 Player
类中写 x = 2
和调用 Print()
,因为 Player
是 Entity
的子类。然而,我仍然不能在 main()
函数里面这样做,因为它是一个完全不同的函数,且在类外面。
public
最后是 public
,它意味着所有人都可以访问它:我可以在 Entity
类中访问它、在 Player
类中访问它,也可以在 main()
函数中访问:
|
现在我们来谈谈为什么要使用可见性,哪里要用到,为什么不让所有的东西都是 public
呢?
public
公开一切对于开发者而言纯粹是一个糟糕的想法,这是如何写好代码问题。不管是阅读代码还是扩展代码,可见性让代码更加容易维护、容易理解。这与性能无关,也不会产生完全不同的代码,可见性不是 CPU 需要理解的东西,它只是人类为了帮助自己和他人发明的东西。
所以当我说帮助他人时,我的意思是如果你把某件事标记为 private
,这基本上告诉每个人:“嘿,你不应该从其他类或其他代码中访问这个”,你只能在类的内部访问这个。这意味着如果我从来没有使用过一个类,我看它包含了什么,我应该可以这么说:“好吧,我只被允许接触 public
的东西,这就是我使用这个类应该的方式”。
这是这个类的正确用法,实际上是调用公共函数。如果我使用一个类,看到了一个我想调用的私有函数,我知道我不应该调用私有函数,这个类的作者可能提供了一些其他方法来实现同样的事情,如果我能调用私有函数也许不会给我带来我期待的结果,或者破坏其他东西。
代码是个错综复杂的东西,通过明确可见性我们可以确保人们不会调用他们不应该调用的代码。一个很好的例子是 UI 界面,如果我想移动按钮的位置,如果我只访问按钮的坐标 x
和 y
,然后改变变量,按钮实际上可能不会移动(两者的位置改变方式可能不同),为了让按钮真正移动,我们可能需要刷新显示。
隐式转换
今天我们讲 C++ 的隐式构造函数和隐式转换,以及 explicit
关键字是什么意思。
隐式转换
隐式的意思是不会明确地告诉它要做什么,所以有点像自动通过上下文知道意思。C++ 实际上允许编译器对代码执行一次隐式转换,如果我们开始有一个数据类型,然后有了另一个类型,在两者之间 C++ 允许隐式进行转换,而不需要用 cast
做强制转换。
最好用一个例子来说明,让我们来看看。
|
现在我们有了两个构造函数,非常简单。创建这些对象的常见方法如下:
int main(){ |
你也许会毫不犹豫地这样写,因为这实在很简单。也可以加上等号:
int main(){ |
正如大多数人如何使用对象以及如何实例化对象一样。但是,你能做其他人不知道的做法,就是直接将 a
赋值为 YOUSAZOE
:
int main(){ |
这有点奇怪,因为首先你不能用其他语言比如 Java 或 C# 这样搞;其次,Entity b = 20
让 Entity
等于整数?它有一个字符串 name
,但我可以把20赋值给这里,到底发生了什么?
这被称为隐式转换,或叫隐式构造函数。它隐式地将20转换成一个 Entity
,构成出一个 Entity
,因为有一个 Entity
构造函数接受一个整数参数 name
,另一个构造函数接受字符串 name
作为参数。
另一个例子,有一个 printEntity()
函数,参数是 Entity
,用来做打印的:
void printEntity(const Entity& entity) { |
C++ 认为,20可以转换成一个 Entity
,因为你可以调用这个构造函数,20是你创建 Entity
的唯一参数。
现在看看当我尝试通过参数 YOUSAZOE
调用这个 printEntity()
会发生什么?它似乎可以,因为之前的20可以:
int main(){ |
结果是不,它不工作。它没有成功的原因,是这个 YOUSAZOE
不是 std::string
,它是一个 char
数组。
为了让它起作用,C++ 需要做两次转换,一个从 const char
数组到 string
,一个从 string
到 Entity
,但它只允许做一次隐式转换。所以为了让它起作用,我们必须把它包装在一个构造函数中,就像这样:
printEntity(std::string("YOUSAZOE")); |
或者我们可以包装在一个 Entity
对象中。这也是可行的,因为在本例中它将隐式地将这个字符串转换为 std::string
标准字符串,然后它会被推入 Entity
构造函数:
printEntity(Entity("YOUSAZOE")); |
OK,很好,这就是隐式构造函数,非常酷的东西,可以大大简化你的代码。但我尽量避免使用它。
explicit
我们来谈谈 explicit
关键字是什么。它与隐式转换这些有关系,因为 explicit
禁用这个隐式 implicit
功能。explicit
关键字放在构造函数前面,如果你有一个 explicit
的构造函数,这意味着没有隐式的转换,如果要使用整数构造这个 Entity
对象,则必须显式调用此构造函数。
让我们恢复这个隐式转换:
#include <iostream> |
如果我们要消除错误,必须显式地将它转换为一个 Entity
:
int main(){ |
这就是 explicit
关键字的唯一作用了,它需要显式地调用构造函数,而不是每次调用构造函数时 C++ 编译器默认地进行隐式转换。
能用 explicit
的地方比如我有时用于数学库之类的东西,因为我不想总是将数字变成向量,我想确保我的代码尽可能的安全。老实说,我并不经常使用它,当你写低级封装(low level wrapper)或类似的事情时它可以派上用场,它可以防止做意外转换导致的性能问题或 bug。
运算符重载
今天我要讲的是 C++ 的运算符以及运算符重载。
首先,运算符是什么?运算符是我们使用的一种符号,通常代替一个函数来执行一些事情。
我说的不仅仅是数学运算符比如加减乘除这些东西,我们也有其他常用的运算符,实际上我们已经用了很多了,包括逆向引用(derference)运算符、箭头运算符、+=
运算符、还有我们用于内存地址的 &
运算符,也有我们一直在用的左移运算符,也就是两个尖括号把东西打印到 cout
到控制台。
然后我们还有其他运算符,你们可能根本不把它们当作运算符,比如 new
和 delete
实际也是运算符。我们还有非常奇怪完全不同的运算符,比如逗号运算符,圆括号也可以是运算符。我不会列出所有 C++ 中可用的运算符,可以参考网上完整的运算符列表。
所以,运算符重载是什么意思?在这个意义上,重载这个术语本质是给运算符重载赋予新的含义,或者添加参数,或者创建允许在程序中定义或更改运算符的行为。这是一个非常有用的特性,但在 Java 等语言中不受支持,它在 C# 等语言中得到部分支持(通常它好的部分是被支持的)。C++ 给了我们完全的控制权。这是件好事,但也可能是一件坏事,这就是 C++,它给了你很大的控制权,但是它会导致很多糟糕的代码,人们痛恨使用这种语言。
但最终,运算符就是函数,不用给出你的函数名比如 add
,你可以把它交给加号 +
这样的运算符。在很多情况下这真的有助于清理你的代码,使其干净整洁,代码会看起来更好,更容易阅读。
然而,运算符重载的使用应该要非常少,而且只是在完全有意义的情况下。如果人们需要查看运算符的定义,或者类的定义,或者结构的定义,看看运算符到底对它们做了什么,那么你可能会失败。例如,当定义一个 math
类,你需要把两个数学对象加在一起,那么将加法进行重载是很有意义的,因为你可以写代码 a + b
,而且可以运行。
事实上,让我们来看一些例子:
|
我要写一个向量 Vector2
结构体,其中快速定义一个构造函数。在主函数中假设我像存储一个位置以及一个速度。
好了,我有两个向量,现在我想把它们加在一起并存储结果。我想到了这样一个问题,在没有运算符重载的语言下,我怎样才能写好这个?或者你在 C++ 中但你不想使用运算符重载,你可以构造一个 add()
函数:
#include <iostream> |
好吧,听起来很简单,我们已经在主函数派上用场了,看起来还不错。但是如果我们想要通过某种修改来改变 speed
,我们可能用 powerup
使速度稍微快一点,想做 speed
乘以 powerup
之类的事情。
根据这个想法,这意味着我们需要写 speed.mulitply()
:
#include <iostream> |
这里开始看起来有点难读,不幸的是在 Java 这样的语言中,这真的是你唯一的选择。但相反,在 C++ 中我们有运算符重载,这意味着我们可以利用这些运算符,并定义我们自己的运算符来处理 Vector2
结构。所以可以不用写成这样,我们可以把它转换成数学运算符:
#include <iostream> |
因为它们和其他函数一样,我也可以反过来做,不是 operator+
调用 add()
函数,而是 add()
函数调用加法运算符。很多人不知道这一点,因为这个语法看起来有点奇怪,你不经常看到。大部分人通常是写代码的方式:
struct Vector2 { |
但我们也可以用 operator+
像一个函数一样:
Vector2 add(const Vector2& other) const { |
它虽然看起来有点奇怪,但是完全可以编译,只是代码风格不同而已。很显然,后者看起来比前者要好太多了:
Vector2 result1 = position.add(speed.multiply(powerup)); |
好了,我再给你们看一个 std::cout
时使用的左移运算符。假设现在我们有了这个 Vector2
,我们想要把它打印到控制台:
int main(){ |
我们不能这么做,因为这个运算符没有重载。这个运算符接收两个参数,一个是输出流 output stream
也就是 cout
,另一个就是 Vector2
了。我们可以将这个运算符重载加进来:
std::ostream& operator<<(std::ostream& stream, Vector2& other) { |
我们这个重载的左移运算符有点像 ToString()
函数,在 Java 或 C# 等语言中经常被用来重写,这就是 C++ 的厉害之处了,你可以用运算符代替函数。
这里简单的介绍了 C++ 的少部分运算符重载以及通常的工作方式。记住,它们只是函数。当然,如果你愿意,重载一个运算符可以让代码看起来很古怪,但是不要这样做,因为这会让代码难读,也可能会让你自己感到烦躁。
还有其他例子,C# 支持但是 Java 不支持的 ==
运算符。在 Java 中如果你想比较一下,你必须为每个类写一个 equals()
重写,必须到处写 equals()
。如果我想比较 result1
和 result2
,我需要这样写:
if(result1.equals(result2)){ |
我们也可以写 ==
运算符重载:
bool operator==(const Vector2& other) const { |
如果我们需要不等于的运算符,也可以直接调用上面的 ==
:
bool operator!=(const Vector2& other) const { |
this
今天的内容是想讨论一下 this
关键字。
我们在 C++ 中有一个关键字 this
,通过它可以访问成员函数。成员函数意思是一个属于某个类的函数或方法。在方法内部我们也可以引用 this
,this
是一个指向当前对象实例的指针,这个方法属于这个对象实例。
所以当然,在 C++ 中我们可以写一个非静态方法,为了调用这个方法我们需要首先实例化一个对象,然后调用这个方法。这个方法必须用一个有效对象来调用,关键字 this
是指向该对象的指针,这实际上对方法的一般工作方式非常重要。
好了,在我们的代码中我将创建一个 Entity
类:
|
我们当然可以用成员初始化列表,这完全没有问题可以正常工作。然而如果我不想这么做,我想在方法内部写,那可能会遇到一点问题。你可能注意到了,它们的名字完全一样,所以如果我让 x = x
,我实际上只是将这个 x
变量赋值给它自己,也就是什么也不做。
我真正想做的是引用属于这个类的 x
和 y
,实际的类成员, this
关键字可以让我们做到这一点。正如我所提到的,这个 this
关键字是指向当前对象的指针,再讲清楚一点:
Entity(int x,int y):x(x), y(y) { |
这就是 this
的类型(Entity*
),技术上讲如果你把鼠标悬停在上面,你会发现它实际上是一个 Entity* const
。如果我们现在想要赋值 x
,那么我们可以直接用 e->x
:
Entity(int x,int y):x(x), y(y) { |
为了简单一点,我们还可以这样做:
Entity(int x,int y):x(x), y(y) { |
现在我们有了给这两个变量赋值的方法,这是非常重要的,因为如果不用 this
就无法进行赋值了。如果我们想要写一个返回这些变量之一的函数,在函数后面加上 const
是非常常见的,因为它不会修改这个类:
class Entity { |
因此在这个 const
函数中不能将 this
这样赋值给一个 Entity
,而应该是 const Entity
:
-Entity* e = this; |
另一个有用的场合是如果我们想调用这个类之外的函数,那就不是类方法了(类的外部叫函数)。如果我们想在这个类的内部调用一个类外部的函数,其中这个函数将 Entity
作为参数:
#include <iostream> |