| | 提示:第一部分“声明与定义”有点复杂。在看示例之前了解这些概念将有助于您理解示例。反过来,看一个例子会帮助你理解这些概念。我建议您阅读这篇文章,然后在接下来的两个部分中浏览示例。如果这一部分不清楚,回来重读这一部分。 |
在 C# 中,类和其他类型同时声明和定义。即使有partial
关键字,类定义也只是被允许分布在多个文件上;它不会改变声明和定义的组合。该规则的唯一例外是在进行互操作时(使用DllImportAttribute
和extern
关键字来声明在外部 DLL 中定义的函数)。在这种情况下,定义不在 C# 中,但几乎肯定在某些非 C #中。NET 库。(如果 DLL 是. NET 程序集,您可以添加对它的引用并使用它,而不需要任何互操作代码。)
我写这个是因为在 C++ 中,声明和定义通常可以分开,而且经常是分开的。常见的情况是,在头文件中声明一个类(按照惯例,它有一个. H 后缀),并在源文件中定义一个类(按照惯例,它有一个. CPP 后缀)。这不仅适用于类,也适用于独立函数,甚至适用于具有关联成员函数的结构和联合。
期望在. CPP 文件的顶部看到一行或多行#include "SomeHeader.h"
。这些语句告诉编译器(或者更准确地说,告诉预处理器)在那个文件中,或者在包含它的文件中,有一些声明和可能的定义,这些声明和定义是编译器理解后面的 C++ 代码部分所必需的。
在 Visual C++ 中,当包含属于项目一部分或在构建系统的包含路径中找不到的标题时,使用#include
"
HeaderFile.h
"
语法。当包含系统包含文件时,例如 Windows.h,使用#include <Windows.h>
语法。最后,当包含作为 C++ 标准库一部分的包含文件时(我们将在后面详细讨论),使用#include <vector>
语法(即 no。h 包括在内)。用于包含文件的“”与< >语法的含义是由实现定义的,尽管 GCC 和 Visual C++ 对本地头文件使用带引号的语法,对系统头文件使用带括号的语法。
| | 注:原因是。从 C++ 标准库包含文件中去掉了 h 后缀是为了避免与 C++ 编译器的命名冲突,当引入 C++ 标准库时,c++ 编译器已经提供了使用这些名称的头文件。他们是正常的头文件,没有恐惧。 |
要理解为什么 C++ 中声明和定义之间的区别很重要,对 C++ 构建过程有一个基本的了解是很重要的。以下是通常发生的情况:
- 预处理器检查源文件,插入由 include 语句指定的文件文本(以及由 include 语句指定的文件文本,等等。),并且还对任何其他预处理器指令(例如,扩展宏)和任何 pragma 指令进行评估和操作。
- 编译器从预处理器获取输出,并将该代码编译成机器代码,并将其与链接阶段所需的其他信息一起存储在 OBJ 文件中。
- 对项目中的每个源文件重复步骤 1 和 2。
- 链接器检查编译器的输出文件和项目链接的库文件。它会找到编译器在该特定源文件中标识为已声明但未定义的内容的所有位置。然后,它会为定义定位适当的地址,并在中查找该地址的修补程序。
- 一旦所有内容都链接成功,链接器就将所有内容绑定在一起,并输出成品(通常是可执行程序或库文件)。
当然,在这些阶段中的任何一个阶段出现错误都会停止构建过程,前面的描述只是 Visual C++ 构建链的粗略草图。编译器作者在如何做事情上有一定的灵活性。例如,没有要求产生任何中间文件,所以理论上,整个构建过程可以在内存中完成,尽管在实践中,我怀疑有人会这样做。所以把这个列表看作是一个粗略的轮廓,而不是一个精确的描述。
我一直把一切都称为源文件,以保持术语的简单。在 C++ 标准中,源文件及其所有包含文件的这些组合被称为编译单元。我现在提到这一点,只是因为我将进一步使用这个术语。让我们依次考虑三个构建阶段。
预处理器不关心 C++ 声明和定义。事实上,它甚至不在乎你的程序是否是 C++。它对您的源文件的唯一作用是处理所有以#开头的行,从而将它们标记为预处理器指令。只要这些行的格式正确,并且它可以找到所有包含的文件(如果有的话),预处理器就会按照指示添加和删除文本。它会将结果传递给编译器,通常不会将其结果写入文件,因为编译会紧随预处理之后。
编译器确实关心声明和定义,并且非常关心您的程序是否是有效的 C++ 代码。然而,它不需要知道一个函数遇到它时会做什么。它只需要知道函数签名是什么——比如 int AddTwoNumbers(int, int);
。
对于类、结构和联合也是如此;只要编译器知道声明(或者在指针的情况下,只知道特定的标记是类、结构、联合或枚举),那么它就不需要任何定义。仅通过声明,它就知道您对AddTwoNumbers
的调用在语法上是否正确,并且类Vehicle;
实际上是一个类,因此它可以在看到Vehicle* v;,
时创建一个指向它的指针,这就是它所关心的。
链接器确实关心定义。具体来说,它关心的是有一个并且只有一个定义与项目中的每个声明相匹配。唯一的例外是内联函数,它最终会在使用它们的每个编译单元中创建。但是,它们的创建方式避免了多重定义的任何问题。
程序的编译单元之间可以有重复的声明;只要只有一个定义匹配一个声明(内联除外),这样做是改进构建时间的常用技巧。为了确保满足这一定义规则,C++ 编译器倾向于使用名为 mangling 的东西。
这确保了每个声明都与其正确的定义相匹配,包括诸如重载函数和命名空间(如果使用不同的命名空间,则允许重复使用相同的名称)以及嵌套在类、结构或联合中的类、结构、联合和枚举定义等问题。
这个名字会导致可怕的链接器错误,我们将在“内联成员函数”部分看到一个例子。
声明与定义的分离性使您可以构建 C++ 项目,而无需每次都重新编译每个源文件。它还允许您构建使用没有源代码的库的项目。当然,还有其他方法来实现这些目标(例如, C# 使用不同的构建过程)。这是 C++ 的做法;理解基本流有助于理解 C++ 中许多你在 C# 中没有遇到的特性。
C++ 中有两种类型的函数:独立函数和成员函数。它们之间的主要区别在于成员函数属于类、结构或联合,而独立函数不属于。
独立函数是最基本的函数类型。它们可以在名称空间中声明,可以重载,也可以内联。让我们看几个。
示例:函数示例\实用程序
#pragma once
namespace Utility
{
inline bool IsEven(int value)
{
return (value % 2) == 0;
}
inline bool IsEven(long long value)
{
return (value % 2) == 0;
}
void PrintIsEvenResult(int value);
void PrintIsEvenResult(long long value);
void PrintBool(bool value);
}
示例:函数示例\实用程序
#include "Utility.h"
#include <iostream>
#include <ostream>
using namespace std;
using namespace Utility;
void Utility::PrintIsEvenResult(int value)
{
wcout << L"The number " << value << L" is " <<
(IsEven(value) ? L"" : L"not ") << L"even."
<< endl;
}
void Utility::PrintIsEvenResult(long long value)
{
wcout << L"The number " << value << L" is " <<
(IsEven(value) ? L"" : L"not ") << L"even."
<< endl;
}
void Utility::PrintBool(bool value)
{
wcout << L"The value is" <<
(value ? L"true." : L"false.") << endl;
}
示例:函数示例\函数示例. cpp
#include "Utility.h"
#include "../pchar.h"
using namespace Utility;
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
int i1 = 3;
int i2 = 4;
long long ll1 = 6;
long long ll2 = 7;
bool b1 = IsEven(i1);
PrintBool(b1);
PrintIsEvenResult(i1);
PrintIsEvenResult(i2);
PrintIsEvenResult(ll1);
PrintIsEvenResult(ll2);
return 0;
}
头文件实用程序. h 声明并定义了两个内联函数,都被称为IsEven
(使IsEven
成为一个重载函数)。它还声明了另外三个函数:两个叫做PrintIsEvenResult
,一个叫做PrintBool
。源文件 Utility.cpp 定义了最后三个函数。最后,源文件functionsample . CPP使用该代码创建一个简单的程序。
头文件中定义的任何函数都必须内联声明;否则,您将会得到多个定义和一个链接器错误。此外,函数重载的不同之处不仅仅在于它们的返回类型;否则,编译器无法确保您真的获得了所需方法的版本。C# 也是这样,所以这应该不是什么新鲜事。
正如在 Utility.cpp 中看到的,当你定义一个在名字空间中的独立函数时,你需要把名字空间放在函数名之前,并用作用域解析操作符把它分开。如果使用嵌套命名空间,则包括整个命名空间嵌套链,例如void RootSpace::SubSpace::SubSubSpace::FunctionName(int param) { ... };
。
下面的示例包括一个分成头文件和源文件的类。
示例:简单类示例\车辆条件. h
#pragma once
#include <string>
namespace Inventory
{
enum class VehicleCondition
{
Excellent = 1,
Good = 2,
Fair = 3,
Poor = 4
};
inline const std::wstring GetVehicleConditionString(
VehicleCondition condition
)
{
std::wstring conditionString;
switch (condition)
{
case Inventory::VehicleCondition::Excellent:
conditionString = L"Excellent";
break;
case Inventory::VehicleCondition::Good:
conditionString = L"Good";
break;
case Inventory::VehicleCondition::Fair:
conditionString = L"Fair";
break;
case Inventory::VehicleCondition::Poor:
conditionString = L"Poor";
break;
default:
conditionString = L"Unknown Condition";
break;
}
return conditionString;
}
}
示例:简单类示例\车辆
#pragma once
#include <string>
namespace Inventory
{
enum class VehicleCondition;
class Vehicle
{
public:
Vehicle(
VehicleCondition condition,
double pricePaid
);
~Vehicle(void);
VehicleCondition GetVehicleCondition(void)
{
return m_condition;
};
void SetVehicleCondition(VehicleCondition condition);
double GetBasis(void) { return m_basis; };
private:
VehicleCondition m_condition;
double m_basis;
};
}
示例:简单类示例\车辆. cpp
#include "Vehicle.h"
#include "VehicleCondition.h"
using namespace Inventory;
using namespace std;
Vehicle::Vehicle(VehicleCondition condition, double pricePaid) :
m_condition(condition),
m_basis(pricePaid)
{
}
Vehicle::~Vehicle(void)
{
}
void Vehicle::SetVehicleCondition(VehicleCondition condition)
{
m_condition = condition;
}
sample:simple class sample \ simple class sample . CPP
#include <iostream>
#include <ostream>
#include <string>
#include <iomanip>
#include "Vehicle.h"
#include "VehicleCondition.h"
#include "../pchar.h"
using namespace Inventory;
using namespace std;
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
auto vehicle = Vehicle(VehicleCondition::Excellent, 325844942.65);
auto condition = vehicle.GetVehicleCondition();
wcout << L"The vehicle is in " <<
GetVehicleConditionString(condition).c_str() <<
L" condition. Its basis is $" << setw(10) <<
setprecision(2) << setiosflags(ios::fixed) <<
vehicle.GetBasis() << L"." << endl;
return 0;
}
在 Vehicle.h 中,我们从VehicleCondition
枚举类的前向声明开始。我们将在这一章的最后进一步讨论这种技术。现在的要点是(1)我们可以使用这个正向声明或者包含 VehicleCondition.h 头文件,以及(2)声明VehicleCondition
必须在Vehicle
的类定义之前。
为了让编译器为Vehicle
的实例分配足够的空间,它需要知道Vehicle
的每个数据成员有多大。我们可以通过包含适当的头文件或者在某些情况下通过使用一个转发声明来让它知道。如果VehicleCondition
的声明出现在Vehicle
的定义之后,那么编译器会拒绝编译代码,因为编译器不知道VehicleCondition
有多大,甚至不知道它是什么类型的数据。
在这种情况下,一个简单的声明就足以告诉编译器VehicleCondition
是什么(枚举类)以及它有多大。除非另有说明,否则枚举类默认使用int
作为其后备字段。如果我们将支持字段留空,但又说在其他地方使用短、长或其他支持字段类型,编译器会生成不同的错误消息,告诉我们有多个冲突的声明。
然后我们继续定义Vehicle
类。该定义包括其成员函数和成员变量的声明。在大多数情况下,我们不定义成员函数。例外情况是GetVehicleCondition
成员函数和GetBasis
成员函数,我们将在“内联成员函数部分讨论。
我们在 Vehicle.cpp 中定义了Vehicle
的其他成员函数。在这种情况下,成员函数是构造器、析构器和SetVehicleCondition
。通常,像SetVehicleCondition
这样的函数是内联的,在Vehicle
类中简单的构造器和析构器也是内联的。它们在这里被单独定义,以说明当它们不是内联函数时,如何定义这些类型的成员函数。我们将在专门讨论构造器的章节中讨论外观古怪的构造器语法。其余的Vehicle
类代码应该是清楚的。
| | 注意:虽然不要求您采用 ClassName.h 或 ClassName.cpp 文件命名约定,但您几乎可以在任何地方看到它的使用,因为它使代码的使用和维护变得更加容易。 |
VehicleCondition.h 中的GetVehicleConditionString
内联函数返回在该函数中创建的std::wstring
的副本,而不是本地值本身。来自 C#,如果没有使用new
关键词,你可能会觉得这有点奇怪。我们将在存储持续时间一章中讨论自动持续时间类型时探讨这一点。
入口点函数使用一些 C++ 标准库的输入/输出格式化函数。
如前所述,成员函数是类、结构或联合的一部分。简而言之,我将从现在开始以班级成员的身份谈论他们。
静态成员函数可以调用其他静态类成员函数,而不考虑保护级别。静态成员函数也可以显式(即SomeClass::SomeFloat = 20.0f;
)或隐式(即SomeFloat = 20.0f;
)访问静态类成员数据,而不考虑保护级别。
如果您有一个与类成员同名的参数,显式形式会很有帮助。在成员数据前面加上一个m_
,比如m_SomeFloat
,可以消除这个问题,并且在处理类成员数据和局部变量或参数的时候,也能很清楚。那只是风格选择,不是要求。
实例(即非静态)成员函数被自动分配一个this
指针,指向调用它们的实例的实例数据。实例成员函数可以调用其他类成员函数并访问所有类成员数据,或者显式地——与使用this->m_count++ ;
作为实例数据的静态成员相同——或者隐式地——与静态和实例数据(例如,m_data++ ;
)相同,而不管保护级别如何。
在样本类\车辆. h 中,GetVehicleCondition
和GetBasis
成员函数都被声明和定义。这种声明和定义的组合在 C++ 中称为内联成员函数。因为这类似于用 C# 编写方法,所以用 C++ 编写方法可能也很有吸引力。除了一些例外,你不应该这样做。
正如我们之前所讨论的,当您构建一个 C++ 项目时,编译器只会检查您的每个源文件一次。它可能会对同一个源文件进行多次传递来优化它们,但完成后不会再回来。
相比之下,编译器每次将头文件包含在另一个文件中时都会返回到您的头文件,而不管它是源文件还是另一个头文件。这意味着编译器最终会在构建过程中多次运行头文件中的代码。
在 SampleClass\Vehicle.h 头文件的开头,可以看到#pragma once
指令。这是一条有用而重要的线。如果将头文件 A.h 包含在源文件中,然后包含另一个含有 A.h 的头文件,#pragma once
指令会告诉预处理器不要再包含 A.h 的内容。这可以防止预处理器在两个无限期相互包含的头文件之间来回跳转。它还可以防止编译器错误。如果多次包含 A.h ,当编译器从第二次包含 A.h 到达类型定义时会失败。
即使有了这个指令,编译器仍然需要包含并解析包含它的每个源文件的头文件代码。在头文件中放入的内容越多,构建每个源文件所需的时间就越长。这增加了编译时间,正如您将发现的那样,与 C# 相比,C++ 项目的编译时间可能相当长。
当您在头文件中内联成员函数的定义时,C++ 编译器可以在使用该函数的任何源文件中内联该代码。这通常会导致更快的程序执行,因为不需要调用函数,程序可以简单地就地运行代码。
范围由编译器保留,因此您不需要担心在内联函数和使用它的函数中定义的变量之间的命名冲突。在处理代码时,例如前面的示例,您只需检索成员变量值,内联定义可以提高速度,尤其是当代码在循环中执行时。
还有一种定义内联成员函数的替代方法。如果您想保持类定义的整洁,其中没有成员函数定义,但是仍然希望有一些内联成员函数,那么您可以做类似下面这样的事情:
示例:SimpleClassSample\Vehicle.h(在文件底部注释掉的替代代码)。
#pragma once
#include <string>
namespace Inventory
{
enum class VehicleCondition;
class Vehicle
{
public:
Vehicle(
VehicleCondition condition,
double pricePaid
);
~Vehicle(void);
inline VehicleCondition GetVehicleCondition(void);
void SetVehicleCondition(VehicleCondition condition);
inline double GetBasis(void);
private:
VehicleCondition m_condition;
double m_basis;
};
VehicleCondition Vehicle::GetVehicleCondition(void)
{
return m_condition;
}
double Vehicle::GetBasis(void)
{
return m_basis;
}
}
如您所见,在类声明之后,我们定义了我们想要内联的成员函数,就像它们在源文件中一样。关键区别在于类中的函数声明前面有inline
关键字,函数定义在头文件本身。如果您关闭该关键字,您将得到一个链接器错误,如图 1 所示。
图 1:离开Vehicle::GetVehicleCondition
成员函数的inline
关键字
的结果。
顺便说一下,链接器错误看起来总是很可怕。原因是链接器不再知道你的变量和函数在源文件中的名字。它只知道编译器将这些名称转换成什么,以便使所有名称唯一。这包括重载方法,重载方法在链接阶段需要一个唯一的名称,以便链接器可以将对重载成员函数的调用连接到该函数的正确重载版本。
图 1 中的错误只是告诉我们我们不止一次定义了Inventory::Vehicle::GetVehicleCondition(void)
。现在,我们知道我们只定义了一次,只是在头文件中,但是我们在 SimpleClassSample 项目的 Vehicle.cpp 和 Main.cpp 中都包含了头文件。
由于我们故意忘记了将inline
关键字添加到Vehicle::GetVehicleCondition
函数声明中,编译器不会将代码内联。相反,它在 Main.cpp 和 Vehicle.cpp 中将其编译为一个函数。
当然,这是编译器可以接受的,因为它将每个源文件视为一个唯一的编译单元。编译器对此一无所知,因为当代码到达时,预处理器阶段已经插入了代码。只有当链接器获得所有编译的代码并试图匹配所有内容时,我们才会到达构建过程说“嘿,我已经有这个函数的另一个版本了!”然后失败了。
如您所见,有两种方法可以内联成员函数。这两者都必须在头文件中完成,因为编译器会在头文件代码被包含时多次评估头文件代码,但它只会在源文件中运行一次。如果你用第二种方法忘记了一个inline
关键字,那么你将会有可怕的链接器错误。如果您使用第二种方法并记住inline
关键字,但在源文件中定义函数,您将得到可怕的链接器错误——这次说没有定义。
| | 提示:不要试图让一切都在线。你最终会发现编译速度很慢,这会降低你的工作效率。做一些有意义的内联事情,比如简单的成员变量的 getter 和 setter 函数。像其他任何事情一样,首先分析,然后根据需要进行优化。 |
成员函数和成员数据有三种可能的访问说明符:
public
protected
private
这些访问说明符表示成员的可访问性级别。在样本类\车辆. h 中,您可以看到如何使用这些的两个例子。请注意,与 C# 不同,您不会在每个成员前面重述访问说明符。相反,您应该声明访问说明符,后跟一个冒号(例如,public:)
),然后后面的每个声明和定义都被赋予该级别的可访问性,直到您到达另一个访问说明符。
默认情况下,类成员是私有的。这意味着,如果在类声明的开头没有访问说明符,那么声明的所有成员都将是私有的,直到达到访问说明符。如果没有达到,你会有一个完全私人的类,这将是非常奇怪的。
结构成员默认为公共的,所以有时你会看到一个没有任何访问说明符的结构。但是,如果你想在一个结构中使用它们,它们的工作原理和在一个类中一样。
最后,您可以多次使用同一个访问说明符;如果您想要组织您的类,以便您首先定义成员函数,然后定义成员变量(反之亦然),您可以很容易地做类似这样的事情:
#include <string>
class SomeClass
{
public:
SomeClass(void);
virtual ~SomeClass(void);
int AddTwoInts(int, int);
void StoreAString(const wchar_t*);
private:
bool CheckForIntAdditionOverflow(int, int);
public:
int SomePublicInteger;
protected:
std::wstring m_storedString;
};
前面的类定义没有定义任何特别有用的东西。然而,它确实是使用所有三个访问说明符的一个例子。它还演示了可以不止一次地使用说明符,比如前面例子中的public
。
在 C++ 中指定类派生的类时,还应该指定访问说明符。如果没有,您将获得默认的访问级别:类为私有,结构为公共。注意,我说的是类。C++ 支持多重继承。这意味着一个类或结构可以有多个直接基类或结构,不像 C# 中一个类只能有一个父类。
C++ 没有单独的接口类型。一般来说,应该避免多重继承,除非是因为缺少单独的接口。换句话说,一个类应该只有零个或一个真正的基类,以及零个或多个纯粹的抽象类(接口)。不过,这只是个人风格推荐。
多重继承有一些很好的论据。例如,假设您有三组函数。每一个都由函数和数据组成。然后说每一组都与另一组无关——它们之间没有联系,但它们并不相互排斥。在这种情况下,您可能希望将每个函数组放入自己的类中。然后,如果你想创建一个需要这三个组中的两个,或者三个都需要的类,你可以简单地创建一个继承这三个组的类,这样就完成了。
或者,只要在您的公共和受保护成员的函数和变量中没有任何命名冲突,您就完成了。例如,如果三个函数组都有一个成员函数void PrintDiagnostics(void);
会怎么样?你注定要失败,T2,是吗?嗯,原来没有,你不是注定的(通常)。您需要使用一些奇怪的语法来指定您想要哪个基类'PrintDiagnostics
函数。即使这样,你还没有完全完成。
C++ 允许您指定希望类是纯基类还是虚拟基类。您可以通过在基类说明符中的类名前放置或不放置关键字virtual
来实现这一点。我们将很快查看一个解决所有这些问题的示例,但在此之前,重要的是要了解,如果您至少继承一个类两次,并且两次或更多次继承不是虚拟的,您最终将拥有该类数据成员的多个副本
当试图指定您希望使用其中的哪一个时,这会导致一大堆问题。看起来,解决方案是虚拟地从所有事物中派生,但是由于 C++ 实现倾向于解析虚拟成员,这有一个与之相关的运行时性能问题。更好的是,首先尽量避免发生这种情况,但由于这并不总是可能的,所以一定要记住虚拟继承。
现在有一个例子可以让这一切变得有意义:
示例:继承示例\继承示例. cpp
#include <iostream>
#include <ostream>
#include <string>
#include <typeinfo>
#include "../pchar.h"
using namespace std;
class A
{
public:
A(void) : SomeInt(0) { }
virtual ~A(void) { }
const wchar_t* Id(void) const { return L"A"; }
virtual const wchar_t* VirtId(void) const { return L"A"; }
int GetSomeInt(void) const { return SomeInt; }
int SomeInt;
};
class B1 : virtual public A
{
public:
B1(void) :
A(),
m_fValue(10.0f)
{
// Because SomeInt isn't a member of B, we
// cannot initialize it in the initializer list
// before the open brace where we initialize the
// A base class and the m_fValue member data.
SomeInt = 10;
}
virtual ~B1(void) { }
const wchar_t* Id(void) const { return L"B1"; }
virtual const wchar_t* VirtId(void) const override
{
return L"B1";
}
const wchar_t* Conflict(void) const { return L"B1::Conflict()"; }
private:
float m_fValue;
};
class B2 : virtual public A
{
public:
B2(void) : A() { }
virtual ~B2(void) { }
const wchar_t* Id(void) const { return L"B2"; }
virtual const wchar_t* VirtId(void) const override
{
return L"B2";
}
const wchar_t* Conflict(void) const { return L"B2::Conflict()"; }
};
class B3 : public A
{
public:
B3(void) : A() { }
virtual ~B3(void) { }
const wchar_t* Id(void) const { return L"B3"; }
virtual const wchar_t* VirtId(void) const override
{
return L"B3";
}
const wchar_t* Conflict(void) const { return L"B3::Conflict()"; }
};
class VirtualClass : virtual public B1, virtual public B2
{
public:
VirtualClass(void) :
B1(),
B2(),
m_id(L"VirtualClass")
{ }
virtual ~VirtualClass(void) { }
const wchar_t* Id(void) const { return m_id.c_str(); }
virtual const wchar_t* VirtId(void) const override
{
return m_id.c_str();
}
private:
wstring m_id;
};
// Note: If you were trying to inherit from A before inheriting from B1
// and B3, there would be a Visual C++ compiler error. If you
// tried to inherit from it after B1 and B3, there would still be a
// compiler warning. If you both indirectly and directly inherit
// from a class, it is impossible to get at the direct inheritance
// version of it.
class NonVirtualClass : public B1, public B3
{
public:
NonVirtualClass(void) :
B1(),
B3(),
m_id(L"NonVirtualClass")
{ }
virtual ~NonVirtualClass(void) { }
const wchar_t* Id(void) const { return m_id.c_str(); }
virtual const wchar_t* VirtId(void) const override
{
return m_id.c_str();
}
//// If we decided we wanted to use B1::Conflict, we could use
//// a using declaration. In this case, we would be saying that
//// calling NonVirtualClass::Conflict means call B1::Conflict
//using B1::Conflict;
//// We can also use it to resolve ambiguity between member
//// data. In this case, we would be saying that
//// NonVirtualClass::SomeInt means B3::SomeInt, so
//// the nvC.SomeInt statement in
//// DemonstrateNonVirtualInheritance would be legal, even
//// though IntelliSense says otherwise.
//using B3::SomeInt;
private:
wstring m_id;
};
void DemonstrateNonVirtualInheritance(void)
{
NonVirtualClass nvC = NonVirtualClass();
//// SomeInt is ambiguous since there are two copies of A, one
//// indirectly from B1 and the other indirectly from B3.
//nvC.SomeInt = 20;
// But you can access the two copies of SomeInt by specifying which
// base class' SomeInt you want. Note that if NonVirtualClass also
// directly inherited from A, then this too would be impossible.
nvC.B1::SomeInt = 20;
nvC.B3::SomeInt = 20;
//// It is impossible to create a reference to A due to ambiguity.
//A& nvCA = nvC;
// We can create references to B1 and B3 though.
B1& nvCB1 = nvC;
B3& nvCB3 = nvC;
// If we want a reference to some particular A, we can now get one.
A& nvCAfromB1 = nvCB1;
A& nvCAfromB3 = nvCB3;
// To demonstrate that there are two copies of A's data.
wcout <<
L"B1::SomeInt = " << nvCB1.SomeInt << endl <<
L"B3::SomeInt = " << nvCB3.SomeInt << endl <<
endl;
++ nvCB1.SomeInt;
nvCB3.SomeInt += 20;
wcout <<
L"B1::SomeInt = " << nvCB1.SomeInt << endl <<
L"B3::SomeInt = " << nvCB3.SomeInt << endl <<
endl;
// Let's see a final demo of the result. Note that the Conflict
// member function is also ambiguous because both B1 and B3 have
// a member function named Conflict with the same signature.
wcout <<
typeid(nvC).name() << endl <<
nvC.Id() << endl <<
nvC.VirtId() << endl <<
//// This is ambiguous between B1 and B3
//nvC.Conflict() << endl <<
// But we can solve that ambiguity.
nvC.B3::Conflict() << endl <<
nvC.B1::Conflict() << endl <<
//// GetSomeInt is ambiguous too.
//nvC.GetSomeInt() << endl <<
endl <<
typeid(nvCB3).name() << endl <<
nvCB3.Id() << endl <<
nvCB3.VirtId() << endl <<
nvCB3.Conflict() << endl <<
endl <<
typeid(nvCB1).name() << endl <<
nvCB1.Id() << endl <<
nvCB1.VirtId() << endl <<
nvCB1.GetSomeInt() << endl <<
nvCB1.Conflict() << endl <<
endl;
}
void DemonstrateVirtualInheritance(void)
{
VirtualClass vC = VirtualClass();
// This works since VirtualClass has virtual inheritance of B1,
// which has virtual inheritance of A, and VirtualClass has virtual
// inheritance of A, which means all inheritances of A are virtual
// and thus there is only one copy of A.
vC.SomeInt = 20;
// We can create a reference directly to A and also to B1 and B2.
A& vCA = vC;
B1& vCB1 = vC;
B2& vCB2 = vC;
// To demonstrate that there is just one copy of A's data.
wcout <<
L"B1::SomeInt = " << vCB1.SomeInt << endl <<
L"B3::SomeInt = " << vCB2.SomeInt << endl <<
endl;
++ vCB1.SomeInt;
vCB2.SomeInt += 20;
wcout <<
L"B1::SomeInt = " << vCB1.SomeInt << endl <<
L"B3::SomeInt = " << vCB2.SomeInt << endl <<
endl;
// Let's see a final demo of the result. Note that the Conflict
// member function is still ambiguous because both B1 and B2 have
// a member function named Conflict with the same signature.
wcout <<
typeid(vC).name() << endl <<
vC.Id() << endl <<
vC.VirtId() << endl <<
vC.B2::Id() << endl <<
vC.B2::VirtId() << endl <<
vC.B1::Id() << endl <<
vC.B1::VirtId() << endl <<
vC.A::Id() << endl <<
vC.A::VirtId() << endl <<
// This is ambiguous between B1 and B2
//vC.Conflict() << endl <<
// But we can solve that ambiguity.
vC.B2::Conflict() << endl <<
vC.B1::Conflict() << endl <<
// There's no ambiguity here because of virtual inheritance.
vC.GetSomeInt() << endl <<
endl <<
typeid(vCB2).name() << endl <<
vCB2.Id() << endl <<
vCB2.VirtId() << endl <<
vCB2.Conflict() << endl <<
endl <<
typeid(vCB1).name() << endl <<
vCB1.Id() << endl <<
vCB1.VirtId() << endl <<
vCB1.GetSomeInt() << endl <<
vCB1.Conflict() << endl <<
endl <<
typeid(vCA).name() << endl <<
vCA.Id() << endl <<
vCA.VirtId() << endl <<
vCA.GetSomeInt() << endl <<
endl;
}
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
DemonstrateNonVirtualInheritance();
DemonstrateVirtualInheritance();
return 0;
}
| | 注意:上一个示例中的许多成员函数通过在声明中的参数列表后面包含
const
关键字来声明为 const。这个符号是常量正确性概念的一部分,我们将在其他地方讨论。const-member-function 符号唯一的意思是成员函数没有改变类的任何成员数据;在多线程场景中调用它时,不需要担心副作用。编译器强制执行这个符号,这样你就可以确定你标记为 const 的函数真的是 const。 |
前面的示例演示了虚拟成员函数和非虚拟成员函数之间的区别。A 类中的Id
函数是非虚函数,而VirtId
函数是虚函数。结果是,当创建对NonVirtualClass
的基类引用并调用Id
时,我们收到的是基类的版本Id
,而当我们调用VirtId
时,我们收到的是NonVirtualClass
的版本VirtId
。
VirtualClass
当然也是如此。虽然示例小心翼翼地总是为VirtId
的重写指定virtual
和override
(你也应该如此),但是只要A::VirtId
被声明为虚拟的,那么所有具有相同签名的派生类方法都将被视为VirtId
的虚拟重写。
前面的例子也演示了多重继承可能产生的菱形问题以及虚拟继承是如何解决的。钻石问题的绰号来自于这样一种想法,如果类 Z 派生自类 X 和类 Y,这两个类都派生自类 W,那么这个继承关系的图看起来就像一个钻石。没有虚拟继承,继承关系实际上不会形成钻石;相反,它形成了一个双叉,每个叉都有自己的 w
NonVirtualClass
从B1
有非虚继承,从A
有虚继承,从B3
有非虚继承,从A
有非虚继承。这导致了菱形问题,两个A
类成员数据副本成为NonVirtualClass
成员数据的一部分。DemonstrateNonVirtualInheritance
函数显示了由此产生的问题,还显示了当您需要使用A
的一个成员时,用来解决哪个A
的语法。
VirtualClass
既有来自A
的虚继承B1
,也有来自A
的虚继承B2
。由于从VirtualClass
到 A 的所有继承链都是虚拟的,所以 A 的数据只有一个副本;因此,避免了钻石问题。DemonstrateVirtualInheritance
函数显示了这一点。
即使有虚拟继承,VirtualClass
还是有一个歧义。B1::Conflict
和B2::Conflict
都有相同的名称和参数(本例中为无),因此如果不使用基类说明符语法,就不可能解析出您想要的是哪一个。
如果你想避免歧义,在处理多重继承时,命名是非常重要的。然而,有一种方法可以解决歧义。NonVirtualClass
中的两个注释掉的using
声明演示了这种解析机制。如果我们决定要一直以某种方式解决一个模糊问题,那么using
宣言让我们这样做。
| | 注意:
using
声明对于解决类外的歧义也很有用(例如,在名称空间或函数内)。如果您希望仅将某个命名空间中的某些类型纳入范围,而不是使用using namespace
指令将整个命名空间纳入范围,这也很有用。在头中使用using
声明是可以的,只要它在类、结构、联合或函数定义中,因为using
声明被限制在它们存在的范围内。您不应该在这些之外使用它们,因为您会将该类型带入全局命名空间或您所在的任何命名空间的范围内。 |
我在示例中没有涉及的一件事是继承访问说明符,而不是公共的。如果你愿意,你可以写一些类似class B : protected class A { ... }
的东西。那么类A
的成员可以从B
的方法中访问,并且从B,
派生的任何类都可以访问,但是不能公开地访问。你也可以说class B : private class A { ... }
。那么类A
的成员可以从B
的方法中访问,但是从B,
派生的任何类都不能访问,它们也不能公开访问。
我顺便提一下这些,只是因为它们很少被使用。尽管如此,你可能会遇到它们,甚至会发现它们的用处。如果是这样,请记住,从基类私有继承的类仍然可以完全访问该基类;你只是说没有进一步的派生类可以访问基类的成员函数和变量。
更常见的是,您会遇到这样的错误:您或其他人忘记在基类说明符之前键入public
,导致默认的私有继承。您将通过大量错误消息来识别这一点,这些错误消息告诉您不能访问私有成员函数或某个基类的数据,除非您正在编写一个库并且不测试该类。在这种情况下,您将从用户愤怒的怒吼中认识到问题。单元测试是一个好主意的另一个原因。
抽象类至少有一个纯虚拟成员函数。下面的示例显示了如何模拟 C# 接口。
示例:抽象类示例\IWriteData.h
#pragma once
class IWriteData
{
public:
IWriteData(void) { }
virtual ~IWriteData(void) { }
virtual void Write(const wchar_t* value) = 0;
virtual void Write(double value) = 0;
virtual void Write(int value) = 0;
virtual void WriteLine(void) = 0;
virtual void WriteLine(const wchar_t* value) = 0;
virtual void WriteLine(double value) = 0;
virtual void WriteLine(int value) = 0;
};
示例:抽象类示例\ConsoleWriteData.h
#pragma once
#include "IWriteData.h"
class ConsoleWriteData :
public IWriteData
{
public:
ConsoleWriteData(void) { }
virtual ~ConsoleWriteData(void) { }
virtual void Write(const wchar_t* value);
virtual void Write(double value);
virtual void Write(int value);
virtual void WriteLine(void);
virtual void WriteLine(const wchar_t* value);
virtual void WriteLine(double value);
virtual void WriteLine(int value);
};
范例:abstractclass sample \ consoewrite data . CPP
#include <iostream>
#include <ostream>
#include "ConsoleWriteData.h"
using namespace std;
void ConsoleWriteData::Write(const wchar_t* value)
{
wcout << value;
}
void ConsoleWriteData::Write(double value)
{
wcout << value;
}
void ConsoleWriteData::Write(int value)
{
wcout << value;
}
void ConsoleWriteData::WriteLine(void)
{
wcout << endl;
}
void ConsoleWriteData::WriteLine(const wchar_t* value)
{
wcout << value << endl;
}
void ConsoleWriteData::WriteLine(double value)
{
wcout << value << endl;
}
void ConsoleWriteData::WriteLine(int value)
{
wcout << value << endl;
}
示例:抽象类示例\抽象类示例. cpp
#include "IWriteData.h"
#include "ConsoleWriteData.h"
#include "../pchar.h"
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
//// The following line is illegal since IWriteData is abstract.
//IWriteData iwd = IWriteData();
//// The following line is also illegal. You cannot have an
//// instance of IWriteData.
//IWriteData iwd = ConsoleWriteData();
ConsoleWriteData cwd = ConsoleWriteData();
// You can create an IWriteData reference to an instance of a class
// that derives from IWriteData.
IWriteData& r_iwd = cwd;
// You can also create an IWriteData pointer to an instance of a
// class that derives from IWriteData.
IWriteData* p_iwd = &cwd;
cwd.WriteLine(10);
r_iwd.WriteLine(14.6);
p_iwd->WriteLine(L"Hello Abstract World!");
return 0;
}
前面的示例演示了如何在 C++ 中实现接口样式的类。将数据写入日志文件、网络连接或任何其他输出的类可以继承IWriteData
类。通过传递指针或对IWriteData
的引用,您可以轻松切换输出机制。
被称为纯虚函数的抽象成员函数的语法只是在声明后添加= 0
,如IWriteData
类:void Write(int value) = 0;
。你不需要使一个类纯粹抽象;您可以实现成员函数或包含该类所有实例共有的成员数据。如果一个类甚至有一个纯虚函数,那么它就被认为是一个抽象类。
Visual C++ 提供了一种特定于微软的方式来定义一个接口。这相当于使用微软语法的IWriteData
:
示例:抽象类示例\IWriteData.h
#pragma once
__interface IWriteData
{
virtual void Write(const wchar_t* value) = 0;
virtual void Write(double value) = 0;
virtual void Write(int value) = 0;
virtual void WriteLine(void) = 0;
virtual void WriteLine(const wchar_t* value) = 0;
virtual void WriteLine(double value) = 0;
virtual void WriteLine(int value) = 0;
};
不是将其定义为类,而是使用__interface
关键字定义。除了纯虚拟成员函数之外,不能定义构造器、析构器或任何成员函数。除了其他接口之外,您也不能继承任何东西。您不需要包含public
访问说明符,因为所有成员函数都是公共的。
预编译头文件是一种特殊类型的头文件。像一个普通的头文件一样,你可以在其中包含语句和代码定义。它的不同之处在于有助于加快编译时间。
预编译头将在您第一次构建程序时编译。从那时起,只要您不对预编译头或预编译头中直接或间接包含的任何内容进行更改,编译器就可以重用预编译头的现有编译版本。您的编译时间将会加快,因为许多代码(例如,Windows.h 和 C++ 标准库头)不会在每次构建时重新编译。
如果使用预编译头文件,则需要将其作为每个源代码文件的第一个 include 语句。但是,您不应该将其包含在任何头文件中。如果您忘记包含它,或者将其他包含语句放在它上面,那么编译器将生成一个错误。这一要求是预编译头工作方式的结果。
预编译头文件不是 C++ 标准的一部分。它们的实现取决于编译器供应商。如果您对它们有任何疑问,您应该查看编译器供应商的文档,并确保在在线论坛上询问时指定您使用的编译器。
正如我们所讨论的,当您包含一个头文件时,预处理器只需获取所有代码,并将其直接插入到它当前正在编译的源代码文件中。如果该头文件包含其他头文件,那么所有这些文件也会进入。
有些头文件很大。有些包含许多其他头文件。有些是巨大的,包括许多其他头文件。结果是,很多代码最终会被一次又一次地编译,仅仅因为您在另一个头文件中包含了一个头文件。
避免在其他头文件中包含头文件的一种方法是使用转发声明。考虑以下代码:
#pragma once
#include "SomeClassA.h"
#include "Flavor.h"
#include "Toppings.h"
class SomeClassB
{
public:
SomeClassB(void);
~SomeClassB(void);
int GetValueFromSomeClassA(
const SomeClassA* value
);
bool CompareTwoSomeClassAs(
const SomeClassA& first,
const SomeClassA& second
);
void ChooseFlavor(
Flavor flavor
);
void AddTopping(
Toppings topping
);
void RemoveTopping(
Toppings topping
);
private:
Toppings m_toppings;
Flavor m_flavor;
// Other member data and member functions...
};
我们已经包括了 SomeClassA.h 、风味. h 和浇头. h 头文件。SomeClassA
是一个类。Flavor
是限定范围的枚举(特别是enum class
)。Toppings
是未限定范围的枚举。
看看我们的函数定义:我们有一个指向GetValueFromSomeClassA
中SomeClassA
的指针。我们在CompareTwoSomeClassAs
中有两处提到SomeClassA
。然后我们就有了Flavor
和Toppings
的各种用法。
在这种情况下,我们可以删除所有三个 include 语句。为什么呢?因为要编译这个类定义,编译器只需要知道SomeClassA
的类型以及Flavor
和Toppings
的底层数据类型。我们可以用正向声明告诉编译器所有这些。
#pragma once
class SomeClassA;
enum class Flavor;
enum Toppings : int;
class SomeClassB
{
public:
SomeClassB(void);
~SomeClassB(void);
int GetValueFromSomeClassA(
const SomeClassA* value
);
bool CompareTwoSomeClassAs(
const SomeClassA& first,
const SomeClassA& second
);
void ChooseFlavor(
Flavor flavor
);
void AddTopping(
Toppings topping
);
void RemoveTopping(
Toppings topping
);
private:
Toppings m_toppings;
Flavor m_flavor;
// Other member data and member functions...
};
#pragma once
后的三行告诉编译器它需要知道的一切。听说SomeClassA
是class,
所以可以建立它的类型,用于联动。它被告知Flavor
是一个enum class,
,因此它知道它需要为一个int
(一个enum class
的默认基础类型)预留空间。最后告诉大家Toppings
是enum
,底层类型为int
,也可以为其预留空间。
如果 SomeClassA.h 、调味剂. h 和浇头. h 中那些类型的定义与那些正向声明不匹配,那么您将收到编译器错误。如果你想让一个SomeClassA
实例成为SomeClassB
的成员变量,或者你想直接传递一个作为参数而不是作为指针或引用,那么你需要包含SomeClassA
。然后编译器需要为SomeClassA
保留空间,并且需要它的完整定义来确定它在内存中的大小。最后,您仍然需要将这三个头文件包含在 SomeClassB.cpp 源代码文件中,因为您将在SomeClassB
成员函数定义中使用它们。
那么我们得到了什么?每当你在一个源代码文件中包含 SomeClassB.h 时,该代码文件不会自动包含来自 SomeClassA.h 、风味. h 和topping . h的所有代码,并使用它们进行编译。如果需要,您可以选择包含它们,但是您已经消除了它们的自动包含以及它们包含的任何头文件的自动包含。
假设 SomeClassA.h 包含 Windows.h ,因为除了给你一些价值之外,它还可以在你的应用程序中使用一个窗口。您突然减少了需要在任何源代码文件中编译的代码行(成千上万行),包括 SomeClassB.h 但不包括 SomeClassA.h 或 Windows.h 。如果你在几十个文件中包含 SomeClassB.h ,你会突然想到几十到几十万行代码。
正向声明可以节省几毫秒、几分钟或几小时(对于大型项目)。当然,它们不是所有问题的神奇解决方案,但如果使用得当,它们是一个有价值的工具,可以节省时间。