C--14-快速语法参考-全-
C++14 快速语法参考(全)
一、你好世界
选择 IDE
要开始用 C++ 开发,你需要一个文本编辑器和一个 C++ 编译器。你可以通过安装一个支持 C++ 的集成开发环境(IDE)来同时获得这两者。一个很好的选择是微软的 Visual Studio 社区版,这是一个免费的 Visual Studio 版本,可以从微软的网站上获得。 1 这款 IDE 内置了对 C++11 标准的支持,也包含了 2015 版 C++14 的许多特性。
另外两个流行的跨平台 ide 包括 NetBeans 和 Eclipse CDT。或者,您可以使用简单的文本编辑器(如记事本)进行开发,尽管这不如使用 IDE 方便。如果您选择这样做,只需创建一个文件扩展名为. cpp 的空文档,并在您选择的编辑器中打开它。
创建项目
安装 Visual Studio 后,继续启动程序。然后您需要创建一个项目,它将管理 C++ 源文件和其他资源。转到 Visual Studio 中的文件新建
项目以显示新建项目窗口。从这里,在左侧框架中选择 Visual C++ 模板类型。然后在右侧框架中选择 Win32 控制台应用程序模板。在窗口底部,您可以配置项目的名称和位置。完成后,单击“确定”按钮,将出现另一个名为 Win32 应用程序向导的对话框。单击下一步,将显示几个应用程序设置。将应用程序类型保留为控制台应用程序,并选中空项目复选框。然后单击 Finish,让向导创建您的空项目。
添加源文件
您现在已经创建了一个 C++ 项目。在解决方案资源管理器窗格(视图解决方案资源管理器)中,您可以看到该项目由三个空文件夹组成:头文件、资源文件和源文件。右键单击源文件文件夹并选择添加
新项目。从“添加新项”对话框中选择 C++ 文件(。cpp)模板。
将这个源文件命名为“MyApp ”,然后单击 Add 按钮。现在,一个空的 cpp 文件将被添加到您的项目中,并为您打开。
你好世界
首先要添加到源文件中的是 main 函数。这是程序的入口点,花括号内的代码是程序运行时将要执行的代码。括号及其内容被称为代码块,或简称为代码块。
int main() {}
第一个应用程序将简单地向屏幕输出文本“Hello World”。在此之前,需要包含 iostream 头。这个头文件为程序提供了输入和输出功能,是所有 C++ 编译器附带的标准库文件之一。#include
指令的作用是在文件被编译成可执行文件之前,用指定文件头中的所有内容替换该行。
#include <iostream>
int main() {}
通过包含的 iostream,您可以使用几个新功能。这些都位于名为std
的标准名称空间中,您可以使用双冒号来检查它,也称为范围解析操作符(::
)。在 Visual Studio 中键入此内容后,IntelliSense 窗口将自动打开,显示命名空间包含的内容。在这些成员中,您可以找到cout
流,这是 C++ 中的标准输出流,将用于将文本打印到控制台窗口。它使用两个小于号作为插入操作符(<<
)来表示输出什么。然后可以指定字符串,用双引号分隔,后跟分号。分号在 C++ 中用来标记所有语句的结束。
#include <iostream>
int main()
{
std::cout << "Hello World";
}
使用名称空间
为了简单一点,您可以添加一行代码,指定代码文件使用标准名称空间。然后,您不再需要在名称空间(std::
)前面加上前缀cout
,因为它现在是默认使用的。
#include <iostream>
using namespace std;
int main()
{
cout << "Hello World";
}
智能感知
在 Visual Studio 中编写代码时,只要有多个预先确定的选项可供选择,就会弹出一个名为 IntelliSense 的窗口。也可以通过按 Ctrl+Space 随时手动打开该窗口,以便快速访问您可以在程序中使用的任何代码实体。这是一个非常强大的功能,你应该学会好好利用。
1
二、编译并运行
Visual Studio 编译
继续上一章,Hello World 程序现在已经完成,可以编译和运行了。您可以通过转到“调试”菜单并单击“启动而不调试”(Ctrl + F5)来完成此操作。然后,Visual Studio 编译并运行在控制台窗口中显示文本的应用程序。
如果您从“调试”菜单中选择“开始调试”( F5 ),显示 Hello World 的控制台窗口将在主函数完成后立即关闭。为了防止这种情况,你可以在 main 的末尾添加一个对cin::get
函数 的调用。该函数属于控制台输入流,将从键盘读取输入,直到按下 return 键。
#include <iostream>
using namespace std;
int main()
{
cout << "Hello World";
cin.get();
}
控制台编译
作为使用 IDE 的替代方法,只要您有 C++ 编译器,您也可以从终端窗口编译源文件。例如,在一台 Linux 机器上,你可以使用 GNU C++ 编译器,它可以在几乎所有的 Unix 系统上使用,包括 Linux 和 BSD 家族,作为 GNU 编译器集合(GCC)的一部分。这个编译器也可以通过下载 MinGW 安装在 Windows 上,或者作为 Xcode 开发环境的一部分安装在 Mac 上。
要使用 GNU 编译器,你需要在终端窗口中输入它的名字“g++ ”,并给它输入和输出文件名作为参数。然后,它生成一个可执行文件,该文件在运行时产生的结果与在 Visual Studio 的 Windows 下编译的结果相同。
g++ MyApp.cpp -o MyApp.exe
./MyApp.exe
Hello World
评论
注释用于在源代码中插入注释。它们对最终程序没有影响,只是为了增强代码的可读性,对您和其他开发人员都是如此。C++ 有两种注释符号——单行和多行。单行注释以//开始,延伸到行尾。
*//* single-line comment
多行注释可以跨越多行,并用/和/分隔。
*/** multi-line comment **/*
请记住,空白字符——比如注释、空格和制表符——通常会被编译器忽略。这让你在如何格式化你的代码上有很大的自由度。
1
三、变量
变量用于在程序执行期间存储数据。
数据类型
根据您需要存储的数据,有几种内置数据类型。这些通常被称为基本数据类型或原语。整数类型有short
、int
、long
和long long
。float
、double
和long double
类型是浮点(实数)类型。char
类型保存单个字符,而bool
类型包含 true 或 false 值。
|
数据类型
|
大小(字节)
|
描述
|
| --- | --- | --- |
| 茶 | one | 整数或字符 |
| 短的 | Two | |
| (同 Internationalorganizations)国际组织 | four | 整数 |
| 长的 | 4 或 8 | |
| 很长很长 | eight | |
| 漂浮物 | four | |
| 两倍 | eight | 浮点数 |
| 长双 | 8 或 16 岁 | |
| 弯曲件 | one | 布尔值 |
在 C++ 中,数据类型的确切大小和范围是不固定的。相反,它们依赖于编译程序的系统。上表中显示的大小是大多数 32 位系统上的大小,以 C++ 字节为单位。C++ 中的一个字节是内存的最小可寻址单元,保证至少为 8 位,但也可能是 16 或 32 位,具体取决于系统。根据定义,C++ 中的字符大小是 1 字节。此外,int 类型的大小与处理器的字长相同,因此对于 32 位系统,整数的大小是 32 位。表中的每个整数类型也必须至少与它前面的整数类型一样大。这同样适用于浮点类型,其中每个类型必须至少提供与前一个类型一样的精度。
声明变量
为了声明(创建)一个变量,你从你希望变量保存的数据类型开始,后面跟着一个标识符,这是变量的名字。名称可以由字母、数字和下划线组成,但不能以数字开头。它也不能包含空格或特殊字符,并且不能是保留关键字。
int myInt; // correct int _myInt32; // correct
int 32Int; // incorrect (starts with number)
int Int 32; // incorrect (contains space)
int Int@32; // incorrect (contains special character)
int new; // incorrect (reserved keyword)
分配变量
为了给一个声明的变量赋值,使用等号,它被称为赋值操作符 ( =
)。
myInt = 50;
声明和赋值可以合并成一条语句。当一个变量被赋值时,它就变成了定义的。
int myInt = 50;
在变量被声明的同时,有一种替代的方法来赋值,或者初始化,用圆括号将值括起来。这就是所谓的构造器初始化 ,相当于上面的语句。
int myAlt (50);
如果你需要创建一个以上的相同类型的变量,有一个简单的方法可以使用逗号操作符(,
)。
int x = 1, y = 2, z;
一旦变量被定义(声明和赋值),你就可以通过引用变量的名字来使用它:例如,打印它。
std::cout << x << y; // "12"
变量作用域
变量的作用域指的是可以使用该变量的代码区域。C++ 中的变量既可以全局声明,也可以局部声明。全局变量是在任何代码块之外声明的,声明之后可以从任何地方访问。另一方面,局部变量是在函数内部声明的,只有在声明后才能在函数内部访问。局部变量的生存期也是有限的。一个全局变量将在程序运行期间保持分配状态,而一个局部变量将在其函数执行完毕后被销毁。
int globalVar; // global variable
int main() { int localVar; } // local variable
这些变量的默认值也不同。编译器会自动将全局变量初始化为零,而局部变量根本不会初始化。因此,未初始化的局部变量将包含该内存位置中已经存在的任何垃圾。
int globalVar; // initialized to 0
int main()
{
int localVar; // uninitialized
}
使用未初始化的变量是一个常见的编程错误,可能会产生意想不到的结果。因此,在声明局部变量时,最好总是给它们一个初始值。
int main()
{
int localVar = 0; // initialized to 0
}
整数类型
有四种整数类型可以使用,这取决于你需要变量保存多大的数。
char myChar = 0; // -128 to +127
short myShort = 0; // -32768 to +32767
int myInt = 0; // -2³¹ to +2³¹-1
long myLong = 0; // -2³¹ to +2³¹-1
C++11 标准化了第五种整数类型 long long,它保证至少有 64 位大。许多编译器早在 C++11 标准完成之前就开始支持这种数据类型,包括 Microsoft C++ 编译器。
long long myL2 = 0; // -2⁶³ to +2⁶³-1
要确定数据类型的确切大小,可以使用sizeof
操作符。该运算符返回数据类型在您正在编译的系统中所占的字节数。
std::cout << sizeof(myChar) // 1 byte (per definition)
<< sizeof(myShort) // 2
<< sizeof(myInt) // 4
<< sizeof(myLong) // 4
<< sizeof(myL2); // 8
C++11 中增加了固定大小的整数类型。这些类型属于 std 命名空间,可以通过 cstdint 标准库头包含。
#include <cstdint>
using namespace std;
int8_t myInt8 = 0; // 8 bits
int16_t myInt16 = 0; // 16 bits
int32_t myInt32 = 0; // 32 bits
int64_t myInt64 = 0; // 64 bits
有符号和无符号整数
默认情况下,Microsoft C++ 中的所有数字类型都是有符号的,因此可能同时包含正值和负值。要显式声明一个变量为有符号变量,可以使用关键字signed
。
signed char myChar = 0; // -128 to +127
signed short myShort = 0; // -32768 to +32767
signed int myInt = 0; // -2³¹ to +2³¹-1
signed long myLong = 0; // -2³¹ to +2³¹-1
signed long long myL2= 0; // -2⁶³ to +2⁶³-1
如果你只需要存储正值,你可以声明整数类型为 unsigned
来加倍它们的上限。
unsigned char myChar = 0; // 0 to 255
unsigned short myShort = 0; // 0 to 65535
unsigned int myInt = 0; // 0 to 2³²-1
unsigned long myLong = 0; // 0 to 2³²-1
unsigned long long myL2= 0; // 0 to 2⁶⁴-1
signed
和unsigned
关键字可以作为独立类型使用,是signed int
和unsigned int
的简称。
unsigned uInt; // unsigned int
signed sInt; // signed int
同样,short
和long
数据类型是short int
和long int
的缩写。
short myShort; // short int
long myLong; // long int
数字文字
除了标准的十进制记数法,整数也可以用八进制或十六进制记数法来赋值。八进制文本使用前缀“0”,十六进制文本以“0x”开头下面的两个数字代表同一个数,在十进制记数法中是 50。
int myOct = 062; // octal notation (0)
int myHex = 0x32; // hexadecimal notation (0x)
从 C++14 开始,出现了一种二进制表示法,它使用“0b”作为前缀。这个版本的标准还增加了一个数字分隔符('),可以更容易地阅读长数字。下面的二进制数代表十进制数中的 50。
int myBin = 0b0011'0010; // binary notation (0b)
浮点类型
浮点类型可以存储不同精度级别的实数。
float myFloat; // ~7 digits
double myDouble; // ~15 digits
long double myLongDouble; // typically same as double
上面显示的精度是指数字的总位数。浮点型可以精确地表示 7 位数,而双精度型可以处理 15 位数。试图给一个float
分配 7 个以上的数字意味着最低有效数字将被四舍五入。
myFloat = 12345.678; // rounded to 12345.68
浮点数和双精度数可以用十进制或指数记数法来赋值。指数(科学)记数法是在十进制指数后加上 E 或 E。
myFloat = 3e2; // 3*10² = 300
文字后缀
编译器通常将整数文字(常量)视为 int,或者根据需要将其视为更大的类型以适应该值。可以在字面上加上后缀来改变这种评价。对于整数,后缀可以是 U 和 L 的组合,分别表示无符号和长整型。C++11 还为 long long 类型添加了 LL 后缀。这些字母的顺序和大小写并不重要。
int i = 10;
long l = 10L;
unsigned long ul = 10UL;
除非另外指定,否则浮点文字被视为双精度型。F 或 F 后缀可用于指定文字为浮点类型。同样,L 或 L 后缀指定长双精度类型。
float f = 1.23F;
double d = 1.23;
long double ld = 1.23L;
编译器隐式地将文本转换为任何需要的类型,因此文本的这种类型区分通常是不必要的。如果在给 float 变量赋值时省略了 F 后缀,编译器可能会给出警告,因为从 double 到 float 的转换会损失精度。
字符类型
char
类型通常用于表示 ASCII 字符。这种字符常量用单引号括起来,可以存储在 char 类型的变量中。
char c = 'x'; // assigns 120 (ASCII for 'x')
存储在char
中的数字和打印char
时显示的字符之间的转换自动发生。
std::cout << c; // prints 'x'
对于要显示为字符的另一个整数类型,必须将其显式转换为char
。显式强制转换是通过将所需的数据类型放在要转换的变量或常量前面的括号中来执行的。
int i = c; // assigns 120
std::cout << i; // prints 120
std::cout << (char)i; // prints 'x'
布尔类型
bool
类型可以存储一个布尔值,这个值只能是真或假。这些值由关键字true
和false
指定。
bool b = false; // true or false value
四、运算符
数字运算符是使程序执行特定数学或逻辑操作的符号。C++ 中的数值运算符可以分为五种类型:算术、赋值、比较、逻辑和按位运算符。
算术运算符
有四个基本算术运算符,以及用于获得除法余数的模数运算符(%
)。
int x = 3 + 2; // 5 // addition
x = 3 - 2; // 1 // subtraction
x = 3 * 2; // 6 // multiplication
x = 3 / 2; // 1 // division
x = 3 % 2; // 1 // modulus (division remainder)
请注意,除法符号给出了不正确的结果。这是因为它对两个整数值进行运算,因此会截断结果并返回一个整数。要获得正确的值,必须将其中一个数字显式转换为浮点数。
float f = 3 / (float)2; // 1.5
赋值运算符
第二组是赋值操作符。最重要的是赋值操作符(=
)本身,它给变量赋值。
组合赋值运算符
赋值运算符和算术运算符的一个常见用途是对变量进行运算,然后将结果保存回同一个变量中。这些操作可以用组合赋值操作符来缩短。
x += 5; // x = x+5;
x -= 5; // x = x-5;
x *= 5; // x = x*5;
x /= 5; // x = x/5;
x %= 5; // x = x%5;
递增和递减运算符
另一个常见的操作是将变量加 1 或减 1。这可以用增量(++
)和减量(-)操作符来简化。
x++; // x = x+1;
x--; // x = x-1;
这两者都可以用在变量之前或之后。
x++; // post-increment
x--; // post-decrement
++x; // pre-increment
--x; // pre-decrement
无论使用哪个变量,变量的结果都是相同的。不同的是,后运算符在改变变量之前返回原始值,而前运算符先改变变量,然后返回值。
int x, y;
x = 5; y = x++; // y=5, x=6
x = 5; y = ++x; // y=6, x=6
比较运算符
比较运算符比较两个值并返回真或假。它们主要用于指定条件,即计算结果为 true 或 false 的表达式。
bool b = (2 == 3); // false // equal to
b = (2 != 3); // true // not equal to
b = (2 > 3); // false // greater than
b = (2 < 3); // true // less than
b = (2 >= 3); // false // greater than or equal to
b = (2 <= 3); // true // less than or equal to
逻辑运算符
逻辑运算符通常与比较运算符一起使用。如果左右两边都为真,则逻辑 and ( &&
)计算为真,如果左右两边都为真,则逻辑 or ( ||
)为真。对一个布尔结果取反,有一个逻辑非(!
)运算符。请注意,对于“逻辑与”和“逻辑或”,如果结果已经由左侧确定,则不会计算右侧。
bool b = (true && false); // false // logical and
b = (true || false); // true // logical or
b = !(true); // false // logical not
按位运算符
按位运算符可以操作整数中的单个位。例如,“按位或”运算符(|
)使结果位为 1,如果这些位设置在运算符的任一侧。
int x = 5 & 4; // 101 & 100 = 100 (4) // and
x = 5 | 4; // 101 | 100 = 101 (5) // or
x = 5 ^ 4; // 101 ^ 100 = 001 (1) // xor
x = 4 << 1; // 100 << 1 =1000 (8) // left shift
x = 4 >> 1; // 100 >> 1 = 10 (2) // right shift
x = ~4; // ~00000100 = 11111011 (-5) // invert
按位运算符也有组合赋值运算符。
int x=5; x &= 4; // 101 & 100 = 100 (4) // and
x=5; x |= 4; // 101 | 100 = 101 (5) // or
x=5; x ^= 4; // 101 ^ 100 = 001 (1) // xor
x=5; x <<= 1;// 101 << 1 =1010 (10)// left shift
x=5; x >>= 1;// 101 >> 1 = 10 (2) // right shift
运算符优先级
在 C++ 中,表达式通常从左到右计算。然而,当一个表达式包含多个操作符时,这些操作符的优先级决定了它们被求值的顺序。下表显示了优先级顺序,其中优先级最低的运算符将首先被计算。同样的基本顺序也适用于许多其他语言,如 C、Java 和 C#。
举个例子,逻辑 and ( &&
)绑定弱于关系运算符,关系运算符反过来绑定弱于算术运算符。
bool b = 2+3 > 1*4 && 5/5 == 1; // true
为了使事情更清楚,括号可以用来指定表达式的哪一部分将首先被求值。从表中可以看出,括号是优先级最低的运算符。
bool b = ((2+3) > (1*4)) && ((5/5) == 1); // true
五、指针
指针是一个变量,包含另一个变量的内存地址,称为指针对象。
创建指针 s
指针被声明为任何其他变量,除了在数据类型和指针名称之间放置一个星号(*
)。使用的数据类型决定了它将指向哪种类型的内存。
int* p; // pointer to an integer
int *q; // alternative syntax
指针可以指向相同类型的变量,方法是在该变量前面加上一个“与”号,以便检索其地址并将其分配给指针。与号被称为地址运算符(&
)。
int i = 10;
p = &i; // address of i assigned to p
取消引用指针
上面的指针现在包含了整型变量的内存地址。引用指针将检索这个地址。要获得存储在该地址中的实际值,指针必须以星号为前缀,称为解引用操作符(*
)。
std::cout << "Address of i: " << p; // ex. 0017FF1C
std::cout << "Value of i: " << *p; // 10
当写入指针时,使用相同的方法。如果没有星号,指针将被分配一个新的内存地址,如果有星号,指针所指向的变量的实际值将被更新。
p = &i; // address of i assigned to p
*p = 20; // value of i changed through p
如果创建了第二个指针并赋予了第一个指针的值,那么它将获得第一个指针的内存地址的副本。
int* p2 = p; // copy of p (copies address stored in p)
指向一个指针
有时,拥有一个可以指向另一个指针的指针会很有用。这是通过用两个星号声明一个指针,然后给它分配它将引用的指针的地址来实现的。这样,当存储在第一指针中的地址改变时,第二指针可以跟随该改变。
int** r = &p; // pointer to p (assigns address of p)
引用第二个指针现在给出了第一个指针的地址。解引用第二个指针给出变量的地址,再次解引用它给出变量的值。
std::cout << "Address of p: " << r; // ex. 0017FF28 std::cout << "Address of i: " << *r; // ex. 0017FF1C std::cout << "Value of i: " << **r; // 20
动态分配
指针的主要用途之一是在运行时分配内存——所谓的动态分配 。在迄今为止的例子中,程序只有在编译时为变量声明的那么多可用内存。这被称为静态分配。如果在运行时需要任何额外的内存,new
操作符有可以使用。该运算符允许动态分配内存,内存只能通过指针访问。new
操作符将原始数据类型或对象作为它的参数,它将返回一个指向分配的内存的指针。
int* d = new int; // dynamic allocation
关于动态分配,需要知道的一件重要事情是,当不再需要时,分配的内存不会像程序内存的其余部分一样被释放。相反,它必须用关键字delete
手动释放。这允许您控制动态分配对象的生存期,但也意味着一旦不再需要它,您就要负责删除它。忘记删除已经用new
关键字分配的内存将会给程序带来内存泄漏,因为这些内存将会一直被分配,直到程序关闭。
delete d; // release allocated memory
空指针
当指针没有被分配给有效地址时,它应该被设置为零。这样的指针叫做空指针 。这样做将允许您检查指针是否可以被安全地取消引用,因为有效的指针永远不会为零。
例如,尽管前一个指针已经释放了它的内存,但它存储的地址仍然指向一个现在不可访问的内存位置。试图取消引用这样的指针将导致运行时错误。为了帮助防止这种情况,应该将删除的指针设置为零。请注意,尝试删除已经删除的空指针是安全的。但是,如果指针没有设置为零,再次尝试删除它将导致内存损坏,并可能使程序崩溃。
delete d;
d = 0; // mark as null pointer
delete d; // safe
由于您可能不总是知道一个指针是否有效,所以每当一个指针被取消引用时都应该进行检查,以确保它不为零。
if (d != 0) { *d = 10; } // check for null pointer
常量NULL
也可以用来表示空指针。在 C++ 中,NULL
通常被定义为零,选择使用哪一个是个人喜好的问题。该常量在 stdio.h 标准库文件中定义,该文件包含在 iostream 中。
#include <iostream>
// ...
if (d != NULL) { *d = 10; } // check for null pointer
C++11 引入了关键字 nullptr 来区分 0 和空指针。使用 nullptr 的优点是,与 NULL 不同,它不会隐式转换为整数类型。文本有自己的类型 nullptr_t,它只能隐式转换为指针和 bool 类型。
int* p = nullptr; // ok
int i = nullptr; // error
bool b = nullptr; // ok (false)
nullptr_t mynull = nullptr; // ok
六、引用
引用允许程序员为变量创建一个新名字。它们为指针提供了一种更简单、更安全、功能更弱的替代方式。
创建引用
引用的声明方式与常规变量相同,只是在数据类型和变量名之间附加了一个&符号。此外,在声明引用的同时,必须用指定类型的变量对其进行初始化。
int x = 5;
int& r = x; // r is an alias to x
int &s = x; // alternative syntax
一旦引用被分配或安置,它就不能被重新安置到另一个变量。该引用实际上已成为变量的别名,可以完全像原始变量一样使用。
r = 10; // assigns value to r/x
引用和指针
引用类似于总是指向同一事物的指针。然而,指针是一个指向另一个变量的变量,而引用只是一个别名,没有自己的地址。
int* ptr = &x; // ptr assigned address to x
引用和指针指南
一般来说,只要指针不需要重新赋值,就应该使用引用,因为引用比指针更安全,因为它必须总是引用变量。这意味着不需要检查引用是否指向 null,而指针则需要这样做。引用可能是无效的——例如当引用指向空指针时——但是使用引用比使用指针更容易避免这种错误。
int* ptr = 0; // null pointer
int& ref = *ptr;
ref = 10; // segmentation fault (invalid memory access)
右值引用
C++11 带来了一种新的引用,叫做右值引用。这个引用可以绑定和修改临时对象(右值),比如文字值和函数返回值。通过在类型后放置两个&符号来形成右值引用。
int&& ref = 1 + 2; // rvalue reference
右值引用延长了临时对象的生存期,并允许像普通变量一样使用它。
ref += 3;
cout << ref; // "6"
右值引用的好处是,在处理临时对象时,可以避免不必要的复制。这提供了更好的性能,特别是在处理更大的类型时,如字符串和对象。
七、数组
数组是一种数据结构,用于存储所有具有相同数据类型的值的集合。
数组声明和分配
要声明一个数组,可以像普通的变量声明一样开始,但是在数组名后面附加一组方括号。括号包含数组中元素的数量。这些元素的默认值与变量相同——全局数组中的元素被初始化为默认值,而局部数组中的元素保持未初始化状态。
int myArray[3]; // integer array with 3 elements
数组赋值
要为元素赋值,可以通过将元素的索引放在方括号内(从零开始)来一次引用一个元素。
myArray[0] = 1;
myArray[1] = 2;
myArray[2] = 3;
或者,您可以在声明数组的同时赋值,方法是用花括号将它们括起来。指定的数组长度可以选择省略,让数组大小由赋值的数量决定。
int myArray[3] = { 1, 2, 3 };
int myArray[] = { 1, 2, 3 };
一旦数组元素被初始化,就可以通过引用所需元素的索引来访问它们。
std::cout << myArray[0]; // 1
多维数组
通过添加多组方括号,可以使数组成为多维数组。与一维数组一样,它们可以一次填充一个,也可以在声明过程中一次全部填充。
int myArray[2][2] = { { 0, 1 }, { 2, 3 } };
myArray[0][0] = 0;
myArray[0][1] = 1;
额外的花括号是可选的,但是包含它们是一个很好的实践,因为它使代码更容易理解。
int mArray[2][2] = { 0, 1, 2, 3 }; // alternative
动态数组
因为上面的数组是由静态(非动态)内存组成的,所以在执行之前必须确定它们的大小。因此,大小需要是一个常量值。为了创建一个直到运行时才知道大小的数组,你需要使用动态内存,它是用关键字new
分配的,必须分配给一个指针或引用。
int* p = new int[3]; // dynamically allocated array
C++ 中的数组表现为指向数组中第一个元素的常量指针。因此,数组元素的引用也可以用指针算法来实现。通过将指针递增 1,可以移动到数组中的下一个元素,因为对指针地址的更改会隐式地乘以指针数据类型的大小。
*(p+1) = 10; // p[1] = 10;
数组大小
就像任何其他指针一样,有可能超出数组的有效范围,从而重写一些相邻的内存。这是应该避免的,因为它会导致意想不到的结果或程序崩溃。
int myArray[2] = { 1, 2 };
myArray[2] = 3; // out of bounds error
要确定一个常规(静态分配)数组的长度,可以使用sizeof
操作符。
int length = sizeof(myArray) / sizeof(int); // 2
此方法不能用于动态分配的数组。确定这种数组大小的唯一方法是通过在数组分配中使用的变量。
int size = 3;
int* p = new int[size]; // dynamically allocated array
当你使用完一个动态数组时,你必须记得删除它。这是通过使用带有一组方括号的关键字delete
来完成的。
delete[] p; // release allocated array
八、字符串
C++ 中的string
类用于存储字符串值。在声明字符串之前,必须首先包含字符串头。也可以包含标准名称空间,因为 string 类是该名称空间的一部分。
#include <string>
using namespace std;
然后可以像声明任何其他数据类型一样声明字符串。要将字符串值赋给字符串变量,请用双引号将文本分隔开,然后将它们赋给变量。初始值也可以在声明字符串的同时通过构造器初始化来赋值。
string h = "Hello";
string w (" World");
字符串组合
加号,在这个上下文中称为连接操作符(+
),用于组合两个字符串。它有一个伴随的赋值操作符(+=
)来附加一个字符串。
string a = h + w; // Hello World
h += w; // Hello World
只要串联运算符所操作的字符串之一是 C++ 字符串,它就会起作用。
string b = "Hello" + w; // ok
它不能连接两个 C 字符串或两个字符串文字。为此,必须将其中一个值显式转换为一个string
。
char *c = "World"; // C-style string
b = (string)c + c; // ok
b = "Hello" + (string)" World"; // ok
如果省略了加号,字符串也将被隐式组合。
b = "Hel" "lo"; // ok
转义字符
通过在每一行的末尾加上反斜杠(\
)可以将字符串文字扩展到多行。
string s = "Hello \ World";
要向字符串本身添加新行,需要使用转义符“\n”。
s = "Hello \n World";
这个反斜杠符号用于书写特殊字符,如制表符或换页符。
此外,128 个 ASCII 字符中的任何一个都可以通过写一个反斜杠后跟该字符的 ASCII 码来表示,表示为八进制或十六进制数。
"\07F" // octal character (0-07F)
"\0x177" // hexadecimal character (0-0x177)
从 C++11 开始,通过在字符串前加上一个“R ”,并在双引号内加上一组括号,可以忽略转义字符。这称为原始字符串,例如,可以用来使文件路径更具可读性。
string escaped = "c:\\Windows\\System32\\cmd.exe";
string raw = R"(c:\Windows\System32\cmd.exe)";
字符串比较
比较两个字符串的方法很简单,就是使用等于运算符(==
)。这不会像 C 字符串那样比较字符串的内存地址。
string s = "Hello";
bool b = (s == "Hello"); // true
字符串函数
string
类有很多函数。其中最有用的是length
和size
函数,它们都返回字符串中的字符数。它们的返回类型是size_t
,这是一种无符号数据类型,用于保存对象的大小。这个只是一种内置数据类型的别名,但是它被定义为哪一种在不同的编译器之间是不同的。别名在 crtdefs.h 标准库文件中定义,该文件包含在 iostream 中。
size_t i = s.length(); // 5, length of string
i = s.size(); // 5, same as length()
另一个有用的函数是substr
(子串),它需要两个参数。第二个参数是从第一个参数中指定的位置开始返回的字符数。
s.substr(0,2); // "He"
也可以使用数组符号提取或更改单个字符。
char c = s[0]; // 'H'
字符串编码
双引号中的字符串产生一个 char 类型的数组,它只能容纳 256 个唯一的符号。为了支持更大的字符集,提供了宽字符类型 wchar_t。这种类型的字符串文字是通过在字符串前面加上大写字母“L”来创建的。可以使用 wstring 类存储生成的数组。这个类的工作方式类似于基本的 string 类,但是它使用 wchar_t 字符类型。
wstring s1 = L"Hello";
wchar_t *s2 = L"Hello";
C++11 中引入了固定大小的字符类型,即 char16_t 和 char32_t。这些类型分别提供了 UTF-16 和 UTF-32 编码的明确表示。UTF-16 字符串以“u”为前缀,可以使用 u16string 类存储。同样,UTF-32 字符串以“U”为前缀,存储在 u32string 类中。还添加了前缀“u8”来表示 UTF-8 编码的字符串文字。
string s3 = u8"UTF-8 string";
u16string s4 = u"UTF-16 string";
u32string s5 = U"UTF-32 string";
可以使用转义符“\u”后跟一个表示字符的十六进制数,将特定的 Unicode 字符插入字符串文字。
string s6 = u8"An asterisk: \u002A";
九、条件语句
条件语句用于根据不同的条件执行不同的代码块。
如果语句
if 语句只有在括号内的表达式计算结果为 true 时才会执行。在 C++ 中,这不一定是布尔表达式。它可以是计算结果为数字的任何表达式,在这种情况下,零为假,所有其他数字为真。
if (x < 1) {
cout << x << " < 1";
}
为了测试其他条件,if 语句可以由任意数量的 else if 子句扩展。
else if (x > 1) {
cout << x << " > 1";
}
if 语句的末尾可以有一个 else 子句,如果前面的所有条件都为假,将执行该子句。
else {
cout << x << " == 1";
}
至于花括号,如果只需要有条件地执行一条语句,就可以省去。但是,始终包含它们被认为是一种好的做法,因为它们可以提高可读性。
if (x < 1)
cout << x << " < 1";
else if (x > 1)
cout << x << " > 1";
else
cout << x << " == 1";
交换语句
switch 语句检查整数和一系列事例标签之间的相等性,然后将执行传递给匹配的事例。它可以包含任意数量的 case 子句,并且可以以处理所有其他 case 的默认标签结束。
switch (x)
{
case 0: cout << x << " is 0"; break;
case 1: cout << x << " is 1"; break;
default: cout << x << " is not 1 or 2"; break;
}
注意,每个 case 标签后的语句以关键字break
结束,以跳过开关的其余部分。如果省略了break
,执行将继续到下一个案例,如果需要以相同的方式评估几个案例,这将非常有用。
三元运算符
除了 if 和 switch 语句,还有三元运算符(?:
)可以替换单个 if/else 子句。这个运算符有三个表达式。如果第一个表达式为真,则计算并返回第二个表达式,如果为假,则计算并返回第三个表达式。
x = (x < 0.5) ? 0 : 1; // ternary operator (?:)
C++ 允许表达式作为独立的代码语句使用。因此,三元运算符不仅可以用作表达式,还可以用作语句。
(x < 0.5) ? x = 0 : x = 1; // alternative syntax
编程术语表达式指的是计算出一个值的代码,而语句是以分号或右花括号结束的代码段。
十、循环
C++ 中有三种循环结构,都用于多次执行一个特定的代码块。正如条件 if 语句一样,如果代码块中只有一条语句,则可以省略循环的花括号。
While 循环
只有当条件为真时,while 循环才会遍历代码块,并且只要条件保持为真,就会继续循环。请记住,条件只在每次迭代(循环)开始时检查。
int i = 0;
while (i < 10) { cout << i++; } // 0-9
Do-while 循环
do-while 循环的工作方式与 while 循环相同,只是它在代码块之后检查条件。因此,它将始终至少在代码块中运行一次。注意,这个循环以分号结束。
int j = 0;
do { cout << j++; } while (j < 10); // 0-9
用于循环
for 循环用于在代码块中运行特定的次数。它使用三个参数。第一个初始化一个计数器,并且总是在循环之前执行一次。第二个参数保存循环的条件,并在每次迭代之前进行检查。第三个参数包含计数器的增量,在每次循环结束时执行。
for (int k = 0; k < 10; k++) { cout << k; } // 0-9
for 循环有几种变体。首先,可以使用逗号操作符将第一个和第三个参数分成几个语句。
for (int k = 0, m = 0; k < 10; k++, m--) {
cout << k+m; // 0x10
}
也可以选择省略任何一个参数。
for (;;) {
cout << "infinite loop";
}
C++11 引入了基于范围的 for 循环语法,用于遍历数组和其他容器类型。在每次迭代中,数组中的下一个元素被绑定到引用变量,循环继续,直到遍历完整个数组。
int a[3] = {1, 2, 3};
for (int &i : a) {
cout <<i; // "123"
}
中断并继续
有两个跳转语句可以在循环内部使用:break
和continue
。break
关键字结束循环结构,continue
跳过当前迭代的剩余部分,并在下一次迭代的开始处继续。
for (int i = 0; i < 10; i++)
{
break; // end loop
continue; // start next iteration
}
Goto 语句
第三个跳转语句是goto
,它执行到指定标签的无条件跳转。这条指令通常不被使用,因为它会使执行流程难以遵循。
goto myLabel; // jump to label
myLabel: // label declaration
十一、函数
函数是可重用的代码块,只有在被调用时才会执行。
定义函数
可以通过键入void
后跟函数名、一组括号和一个代码块来创建函数。void
关键字意味着函数不会返回值。函数的命名惯例与变量相同——一个描述性的名称,除了第一个单词,每个单词最初都是大写的。
void myFunction()
{
cout << "Hello World";
}
调用函数
上面的函数在被调用时会简单地打印出一条文本消息。为了从主函数中调用它,函数的名字被指定,后跟一组括号。
int main()
{
myFunction(); // "Hello World"
}
函数参数
函数名后面的括号用于向函数传递参数。为此,必须首先将相应的参数以逗号分隔列表的形式添加到函数声明中。
void myFunction(string a, string b)
{
cout << a + " " + b;
}
一个函数可以被定义为接受任意数量的参数,并且它们可以有任意的数据类型。只要确保使用相同类型和数量的参数调用函数。
myFunction("Hello", "World"); // "Hello World"
准确的说, 参数出现在函数定义中,实参 出现在函数调用中。然而,这两个术语有时可以互换使用。
默认参数值
可以通过在参数列表中为参数赋值来指定默认值。
void myFunction(string a, string b = "Earth")
{
cout << a + " " + b;
}
然后,如果在调用函数时没有指定参数,将使用默认值。为此,有默认值的参数位于没有默认值的参数的右侧是很重要的。
myFunction("Hello"); // "Hello Earth"
函数重载
C++ 中的一个函数可以用不同的参数定义多次。这是一个被称为函数重载的强大特性,它允许一个函数处理各种参数,而使用该函数的程序员不需要知道它。
void myFunction(string a, string b) { cout << a+" "+b; }
void myFunction(string a) { cout << a; }
void myFunction(int a) { cout << a; }
返回语句
函数可以返回值。然后,void
关键字被替换为函数将返回的数据类型,并且return
关键字被添加到函数体中,后跟指定返回类型的参数。
int getSum(int a, int b)
{
return a + b;
}
Return 是一个跳转语句,它使函数退出,并将指定的值返回到调用函数的地方。例如,上面的函数可以作为参数传递给输出流,因为该函数的计算结果是一个整数。
cout << getSum(5, 10); // 15
return 语句也可以在 void 函数中使用,以便在到达结束块之前退出。
void dummy() { return; }
请注意,尽管 main 函数被设置为返回整数类型,但它不必显式返回值。这是因为编译器会自动在主函数的末尾添加一个 return zero 语句。
int main() { return 0; }
远期申报
在 C++ 中要记住的一件重要事情是,函数必须在被调用之前声明。这并不意味着函数必须在被调用之前实现。这只意味着需要在源文件的开头指定函数的头,这样编译器就知道该函数的存在。这种向前声明被称为原型。
void myFunction(int a); // prototype int main()
{
myFunction(0);
}
void myFunction(int a) {}
原型中的参数名称不需要包含在内。必须只指定数据类型。
void myFunction(int);
按值传递
在 C++ 中,默认情况下,原始数据类型和对象数据类型的变量都是通过值传递的。这意味着只有值或对象的副本被传递给函数。所以,以任何方式改变参数都不会影响原来的,传递大对象会很慢。
#include <iostream>
#include <string>
using namespace std;
void change(int i) { i = 10; }
void change(string s) { s = "Hello World"; }
int main()
{
int x = 0; // value type change(x); // value is passed
cout << x; // 0
string y = ""; // reference type
change(y); // object copy is passed
cout << y; // ""
}
通过引用传递
或者,改为通过引用传递变量,您只需要在函数定义中的参数名称前添加一个&符号。当参数通过引用传递时,原始数据类型和对象数据类型都可以被更改或替换,并且这些更改将影响原始数据类型。
void change(int& i) { i = 10; }
int main()
{
int x = 0; // value type
change(x); // reference is passed
cout << x; // 10
}
通过地址
作为通过引用传递的替代方法,也可以使用指针通过地址传递参数。这种传递技术与通过引用传递的目的相同,但使用的是指针语法。
void change(int* i) { *i = 10; }
int main()
{
int x = 0; // value type
change(&x); // address is passed
cout << x; // 10
}
通过值、引用或地址返回
除了通过值、引用或地址传递变量之外,变量也可以通过以下方式之一返回。最常见的是,函数通过值返回,在这种情况下,值的副本被返回给调用者。
int byVal(int i) { return i + 1; }
int main()
{
int a = 10;
cout << byVal(a); // 11
}
相反,为了通过引用返回,在函数的返回类型后放置一个&符号。然后,函数必须返回一个变量,不能像使用“按值返回”时那样返回表达式或文字。返回的变量不应该是局部变量,因为当函数结束时,这些变量的内存被释放。相反,通过引用返回通常用于返回也通过引用传递给函数的参数。
int& byRef(int& i) { return i; }
int main()
{
int a = 10;
cout << byRef(a); // 10
}
为了通过地址返回,解引用操作符被附加到函数的返回类型中。这种返回技术具有与通过引用返回时相同的两个限制——必须返回变量的地址,并且返回的变量不能是函数的局部变量。
int* byAdr(int* i) { return i; }
int main()
{
int a = 10;
cout << *byAdr(&a); // 10
}
内嵌函数
使用函数时要记住的一点是,每次调用函数时,都会产生性能开销。为了潜在地消除这种开销,您可以建议编译器通过使用inline
函数修饰符来内联对特定函数的调用。该关键字最适合在循环内部调用的小函数。它不应该用于较大的函数,因为内联这些函数会严重增加代码的大小,从而降低性能。
inline int myInc(int i) { return i++; }
注意,inline
关键字只是一个建议。编译器在试图优化代码时可能会选择忽略这个建议,也可能内联没有inline
修饰符的函数。
自动和 Decltype
C++11 中引入了两个新的关键字:auto 和 decltype 。这两个关键字都用于编译期间的类型推断。auto 关键字作为一个类型的占位符,指示编译器根据变量的初始化器自动推导出变量的类型。
auto i = 5; // int
auto d = 3.14; // double
auto b = false; // bool
Auto 转换为初始化器的核心类型,这意味着任何引用和常量说明符都被丢弃。
int& iRef = i;
auto myAuto = iRef; // int
可以根据需要手动重新应用删除的说明符。这里的&符号创建一个常规(左值)引用。
auto& myRef = iRef; // int&
或者,可以使用两个&符号。这通常指定一个右值引用,但是在 auto 的情况下,它让编译器根据给定的初始化器自动推导出一个右值或左值引用。
int i = 1;
auto&& a = i; // int& (lvalue reference)
auto&& b = 2; // int&& (rvalue reference)
自动说明符可以用在任何声明和初始化变量的地方。例如,下面的 For 循环迭代器的类型被设置为 auto,因为编译器可以很容易地推断出类型。
#include <vector>
using namespace std;
// ...
vector<int> myVector { 1, 2, 3 };
for (auto& x : myVector) { cout << x; } // "123"
在 C++11 之前,没有基于范围的 for 循环或 auto 说明符。迭代一个向量需要更冗长的语法。
for(vector<int>::size_type i = 0; i != myVector.size(); i++) {
cout << myVector[i]; // "123"
}
decltype 说明符的工作方式类似于 auto,只是它推导出给定表达式的确切声明类型,包括引用。该表达式在括号中指定。
decltype(3) b = 3; // int&&
在 C++14 中,auto 可以用作 decltype 的表达式。然后用初始化表达式替换关键字 auto,这样就可以推导出初始化器的确切类型。
decltype(auto) = 3; // int&&
当初始化器可用时,使用 auto 通常是更简单的选择。Decltype 主要用于转发函数返回类型,不用考虑是引用类型还是值类型。
decltype(5) getFive() { return 5; } // int
C++11 增加了尾随返回类型语法,允许在参数列表后指定函数的返回值,跟在箭头操作符(->)之后。这使得在用 decltype 推导返回类型时可以使用该参数。在 C++11 的上下文中使用 auto 仅仅意味着使用了尾随返回类型语法。
auto getValue(int x) -> decltype(x) { return x; } // int
C++14 中增加了使用 auto 进行返回类型演绎的函数。这使得核心返回类型可以直接从 return 语句中推导出来,
auto getValue(int x) { return x; } // int
此外,auto 可以与 decltype 一起使用,按照 decltype 的规则推导出确切的类型。
decltype(auto) getRef(int& x) { return x; } // int&
类型推导的主要用途是减少代码的冗长性并提高可读性,尤其是在声明复杂类型时,在这种情况下,类型要么难以识别,要么难以编写。请记住,在现代 ide 中,您可以将鼠标悬停在一个变量上来检查它的类型,即使该类型是自动推导出来的。
λ函数
C++11 增加了创建 lambda 函数的能力,这些函数是未命名的函数对象。这提供了一种在使用点定义函数的简洁方法,而不必在其他地方创建命名函数。下面的示例创建一个 lambda,它接受两个 int 参数并返回它们的总和。
auto sum = [](int x, int y) -> int
{
return x + y;
};
cout << sum(2, 3); // "5"
如果编译器可以从 lambda 推导出返回值,那么包含返回类型是可选的。在 C++11 中,这要求 lambda 只包含一个返回语句,而 C++14 将返回类型演绎扩展到任何 lambda 函数。注意,省略返回类型时,箭头操作符(->)也被省略。
auto sum = [](int x, int y) { return x + y; };
C++11 要求用具体类型声明 lambda 参数。这个要求在 C++14 中被放宽了,允许 lambdas 使用自动类型演绎。
auto sum = [](auto x, auto y) { return x + y; };
Lambdas 通常用于指定只引用一次的简单函数,通常是通过将 function 对象作为参数传递给另一个函数。这可以通过使用具有匹配参数列表和返回类型的函数包装来完成,如下例所示。
#include <iostream>
#include <functional>
using namespace std;
void call(int arg, function<void(int)> func) {
func(arg);
}
int main() {
auto printSquare = [](int x) { cout << x*x; };
call(2, printSquare); // "4"
}
所有的 lambdas 都以一组方括号开始,称为 capture 子句。该子句指定了可以在 lambda 主体中使用的周围范围的变量。这有效地将额外的参数传递给 lambda,而不需要在函数包装的参数列表中指定这些参数。因此,前面的例子可以用下面的方式重写。
void call(function<void()> func) { func(); }
int main() {
int i = 2;
auto printSquare = [i]() { cout << i*i; };
call(printSquare); // "4"
}
这里的变量是通过值捕获的,所以在 lambda 中使用了一个副本。变量也可以通过引用使用熟悉的&前缀来捕获。注意,lambda 在这里是在同一个语句中定义和调用的。
int a = 1;
&a { a += x; }(2);
cout << a; // "3"
可以指定一个默认的捕获模式,以指示如何捕获 lambda 中使用的任何未指定的变量。[=]表示变量是通过值捕获的,而[&]是通过引用捕获的。由值捕获的变量通常是常量,但是可变说明符可以用来允许修改这样的变量。
int a = 1, b = 1;
[&, b]() mutable { b++; a += b; }();
cout << a << b; // "31"
从 C++14 开始,变量也可以在 capture 子句中初始化。如果在外部作用域中没有同名的变量,那么变量的类型将被自动推导出来。
int a = 1;
[&, b = 2]() { a += b; }();
cout << a; // "3"
十二、类
类是用于创建对象的模板。要定义一个,使用关键字class
,后跟一个名称、一个代码块和一个分号。类的命名约定是混合大小写,这意味着每个单词最初都应该大写。
class MyRectangle {};
类成员可以在类内部声明;两种主要类型是字段和方法。字段是变量,它们保存对象的状态。方法是函数,它们定义了对象能做什么。
class MyRectangle
{
int x, y;
};
类方法
属于一个类的方法通常被声明为该类内部的原型,而实际的实现放在该类的定义之后。然后,类之外的方法名需要以类名和范围解析操作符为前缀,以指定方法定义属于哪个类。
class MyRectangle
{
int x, y;
int getArea();
};
int MyRectangle::getArea() { return x * y; }
内嵌方法
如果方法很短,并且您想建议编译器将函数的代码插入(内联)到调用者的代码中,一种方法是在方法的定义中使用inline
关键字。
inline int MyRectangle::getArea() { return x * y; }
更方便的方法是简单地在类内部定义方法。这将隐式地向编译器建议应该内联该方法。
class MyRectangle
{
int x, y;
int getArea() { return x * y; }
};
对象创建
类定义现在完成了。为了使用它,你首先必须创建一个类的对象,也称为实例。这可以通过声明变量的相同方式来实现。
int main()
{
MyRectangle r; // object creation
}
访问对象成员
在访问该对象包含的成员之前,首先需要在类定义中将它们声明为 public,方法是使用关键字public
后跟一个冒号。
class MyRectangle
{
public:
int x, y;
int getArea() { return x * y; }
};
现在可以在实例名称后使用点运算符(.
)来访问该对象的成员。
r.x = 10;
r.y = 5;
int z = r.getArea(); // 50 (5*10)
基于一个类可以创建任意数量的对象,每个对象都有自己的一组字段和方法。
MyRectangle r2; // another instance of MyRectangle
r2.x = 25; // not same as r.x
使用对象指针时,箭头操作符(->
)允许访问对象的成员。该运算符的行为类似于点运算符,只是它首先取消对指针的引用。它专门用于指向对象的指针。
MyRectangle r;
MyRectangle *p = &r; // object pointer
p->getArea();
(*p).getArea(); // alternative syntax
远期申报
类和函数一样,必须在被引用之前声明。如果一个类定义没有出现在对该类的第一次引用之前,那么可以在引用之上指定一个类原型。
class MyClass; // class prototype
这种向前声明允许在任何不需要完全定义该类的上下文中引用该类。
class MyClass; // class prototype
MyClass* p; // allowed
MyClass f(MyClass&); // allowed
MyClass o; // error, definition required
sizeof(MyClass); // error, definition required
注意,即使有了原型,你仍然不能在定义之前创建一个类的对象。
十三、构造器
除了字段和方法,一个类可以包含一个构造器。这是一种特殊的方法,用于构造或者实例化对象。它总是与类同名,并且没有返回类型。要从另一个类访问,需要在标记有public
访问修饰符的部分声明构造器。
class MyRectangle
{
public:
int x, y; MyRectangle();
};
MyRectangle::MyRectangle() { x = 10; y = 5; }
当创建该类的新实例时,将调用构造器方法,在这种情况下,该方法为字段分配默认值。
int main()
{
MyRectangle s;
}
构造器重载
与任何其他方法一样,构造器可以重载。这将允许用不同的参数列表创建一个对象。
class MyRectangle
{
public:
int x, y; MyRectangle(); MyRectangle(int, int);
};
MyRectangle::MyRectangle() { x = 10; y = 5; }
MyRectangle::MyRectangle(int a, int b) { x = a; y = b; }
例如,使用上面定义的两个构造器,对象可以不带参数初始化,也可以带两个参数初始化,这两个参数将用于分配字段。
// Calls parameterless constructor
MyRectangle r;
// Calls constructor accepting two integers
MyRectangle t(2,3);
C++11 增加了构造器调用其他构造器的能力。使用这个特性,前面创建的无参数构造器在这里被重新定义来调用第二个构造器。
MyRectangle::MyRectangle(): MyRectangle(10, 5);
这个关键字
在构造器内部,以及在属于对象的其他方法中——所谓的实例方法——可以使用一个叫做this
的特殊关键字。这是指向该类的当前实例的指针。例如,如果构造器的参数名与字段名相同,这将非常有用。这些字段仍然可以通过使用this
指针来访问,即使它们被参数所掩盖。
MyRectangle::MyRectangle(int x, int y)
{
this->x = x; this->y = y;
}
字段初始化
作为在构造器内部分配字段的替代方法,它们也可以通过使用构造器初始化列表来分配。该列表以构造器参数后的冒号开头,后面是对字段自身构造器的调用。这实际上是通过构造器分配字段的推荐方式,因为它比在构造器内分配字段提供了更好的性能。
MyRectangle::MyRectangle(int a, int b) : x(a), y(b) {}
字段也可以在它们的类定义中被赋予一个初始值,这是 C++11 中添加的一个方便的特性。当创建新实例时,在运行构造器之前,会自动分配该值。因此,这种赋值可用于为可能在构造器中被重写的字段指定默认值。
class MyRectangle
{
public:
// Class member initialization
int x = 10;
int y = 5;
};
默认构造器
如果没有为一个类定义构造器,编译器会在程序编译时自动创建一个缺省的无参数构造器。因此,即使没有实现构造器,类也可以被实例化。默认构造器将只为对象分配内存。它不会初始化字段。与全局变量不同,C++ 中的字段不会自动初始化为默认值。这些字段将包含留在它们的内存位置中的任何垃圾,直到它们被显式赋值。
破坏者
除了构造器,一个类还可以有一个显式定义的析构函数。析构函数用于释放对象分配的任何资源。在对象被销毁之前,或者当对象超出范围时,或者当它被用new
操作符创建的对象显式删除时,它被自动调用。析构函数的名称与类名相同,但前面有一个波浪号(~
)。一个类只能有一个析构函数,它从不接受任何参数或返回任何东西。
class Semaphore
{
public:
bool *sem;
Semaphore() { sem = new bool; }
~Semaphore() { delete sem; }
};
特殊成员功能
默认的构造器和析构函数都是特殊的成员函数,编译器会自动提供给没有明确定义它们的类。另外两个这样的方法是复制构造器和复制赋值操作符(operator =)。C++11 标准提供了通过删除和默认说明符来控制是否允许这些特殊成员函数的方法。delete 说明符禁止调用函数,而 default 说明符明确声明将使用编译器生成的默认值。
class A
{
public:
// Explicitly include default constructor
A() = default;
A(int i);
// Disable copy constructor
A(const A&) = delete;
// Disable copy assignment operator
A& operator=(const A&) = delete;
};
对象初始化
C++ 提供了许多不同的方法来创建对象和初始化它们的字段。下面的类将用来说明这些方法。
class MyClass
{
public:
int i;
MyClass() = default;
MyClass(int x) : i(x) {}
};
直接初始化
到目前为止一直使用的对象创建语法叫做直接初始化。该语法可以包括一组括号,用于将参数传递给类中的构造器。如果使用无参数构造器,括号将被省略。
// Direct initialization
MyClass a(5); MyClass b;
值初始化
一个对象也可以被值初始化。然后,通过使用类名后跟一组括号来创建对象。圆括号可以提供构造器参数,或者保留为空以使用无参数构造器构造对象。值初始化只创建一个临时对象,该对象在语句结束时被销毁。要保留该对象,必须将其复制到另一个对象或指定给一个引用。将临时对象分配给引用将维护该对象,直到该引用超出范围。
// Value initialization
const MyClass& a = MyClass();
MyClass&& b = MyClass();
初始化对象值与使用默认初始化创建对象值几乎相同。一个微小的区别是,在某些情况下,当使用值初始化时,非静态字段将被初始化为它们的默认值。
复制初始化
如果一个现有对象在声明时被分配给一个相同类型的对象,新对象将被复制初始化。这意味着现有对象的每个成员都将被复制到新对象中。
// Copy initialization
MyClass a = MyClass();
MyClass b(a);
MyClass c = b;
这是因为编译器提供了隐式的复制构造器,这种类型的赋值都会调用它。复制构造器接受其自身类型的单个参数,然后构造指定对象的副本。请注意,这种行为不同于许多其他语言,如 Java 和 C#。在这些语言中,用另一个对象初始化一个对象只会复制该对象的引用,而不会创建新的对象副本。
新初始化
一个对象可以通过使用new
关键字的动态内存分配来初始化。动态分配的内存必须通过指针或引用来使用。new
操作符返回一个指针,所以要将它赋给一个引用,首先需要取消引用。请记住,一旦不再需要动态分配的内存,就必须显式释放它。
// New initialization
MyClass* a = new MyClass(); MyClass& b = *new MyClass();
// ...
delete a, b;
聚合初始化
初始化名为聚合初始化的对象时,有一个语法快捷方式可用。这种语法允许使用一个用大括号括起来的初始值设定项列表来设置字段,就像使用数组一样。只有当类类型不包含任何构造器、虚函数或基类时,才能使用聚合初始化。这些字段也必须是公共的,除非它们被声明为静态的。每个字段将按照它们在类中出现的顺序进行设置。
// Aggregate initialization
MyClassa = { 2 }; // iis 2
统一初始化
统一初始化是在 C++11 中引入的,目的是提供一种一致的方法来初始化类型,这种方法对任何类型都适用。该语法看起来与聚合初始化相同,只是没有使用等号。
// Uniform initialization
MyClass a { 3 }; // i is 3
这种初始化语法不仅适用于类,还适用于任何类型,包括原语、字符串、数组和 vector 等标准库容器。
#include <string>
#include <vector>
using namespace std;
int i { 1 };
string s {"Hello"};
int a[] { 1, 2 };
int *p= new int [2] { 1, 2 };
vector<string> box { "one", "two" };
统一初始化可用于调用构造器。这是通过为该构造器传递适当的参数自动完成的。
// Call parameterless constructor
MyClass b {};
// Call copy constructor
MyClass c { b };
一个类可以定义一个初始化列表构造器。如果为 initializer_list 模板指定的类型与用大括号括起来的参数列表的类型相匹配,则该构造器在统一初始化期间被调用,并且优先于其他形式的构造。论点单可以是任意长度,但所有元素必须是同一类型。在下面的例子中,列表的类型是 int,因此用于构造该对象的整数列表被传递给构造器。然后使用基于范围的 for 循环显示这些整数。
#include <iostream>
using namespace std;
class NewClass
{
public:
NewClass(initializer_list<int> args)
{
for (auto x : args)
cout << x << " ";
}
};
int main()
{
NewClass a { 1, 2, 3 }; // "1 2 3"
}
十四、继承
继承允许一个类获得另一个类的成员。在下面的示例中,Square 继承了 Rectangle。这是在类名后指定的,使用冒号后跟public
关键字和要继承的类名。然后 Rectangle 成为 Square 的基类,Square 又成为 Rectangle 的派生类。除了它自己的成员,Square 还获得了 Rectangle 中所有可访问的成员,除了它的构造器和析构函数。
class Rectangle
{
public:
int x, y;
int getArea() { return x * y; }
};
class Square : public Rectangle {};
向上抛
一个对象可以被向上转换为它的基类,因为它包含了基类所包含的一切。向上转换是通过将对象分配给其基类类型的引用或指针来执行的。在下面的例子中,一个正方形对象被向上投射为矩形。当使用 Rectangle 的接口时,Square 对象将被视为一个矩形,因此只能访问 Rectangle 的成员。
Square s;
Rectangle& r = s; // reference upcast
Rectangle* p = &s; // pointer upcast
派生类可以用在任何需要基类的地方。例如,正方形对象可以作为参数传递给需要矩形对象的函数。然后,派生的对象将隐式地向上转换为它的基类型。
void setXY(Rectangle& r) { r.x = 2; r.y = 3; }
int main()
{
Square s;
setXY(s);
}
向下铸造
指向正方形对象的矩形引用可以向下转换回正方形对象。这种向下转换必须是显式的,因为不允许将实际的矩形向下转换为正方形。
Square& a = (Square&) r; // reference downcast
Square& b = (Square&) *p; // pointer downcast
构造器继承
为了确保基类中的字段被正确初始化,当派生类的对象被创建时,基类的无参数构造器被自动调用。
class B1
{
public:
int x;
B1() : x(5) {}
};
class D1 : public B1 {};
int main()
{
// Calls parameterless constructors of D1 and B1
D1 d;
cout << d.x; // "5"
}
通过将派生构造器放在构造器的初始化列表中,可以从派生构造器显式调用基构造器。这允许将参数传递给基构造器。
class B2
{
public:
int x;
B2(int a) : x(a) {}
};
class D2 : public B2
{
public:
D2(int i) : B2(i) {} // call base constructor
};
在这种情况下,另一种解决方案是继承构造器。从 C++11 开始,这可以通过 using 语句来完成。
class D2 : public B2
{
public:
using B2::B2; // inherit all constructors
int y{0};
};
请注意,基类构造器不能初始化派生类中定义的字段。因此,派生类中声明的任何字段都应该自己初始化。这是使用统一符号来完成的。
多重继承
C++ 允许派生类从多个基类继承。这叫做多重继承。基类在逗号分隔的列表中指定。
class Person {}
class Employee {}
class Teacher: public Person, public Employee {}
多重继承并不常用,因为大多数现实世界的关系可以用单一继承来更好地描述。它还会显著增加代码的复杂性。
十五、覆盖
派生类中的新方法可以重新定义基类中的方法,以便为其提供新的实现。
隐藏派生成员
在下面的例子中,Rectangle 的getArea
方法用相同的签名在 Triangle 中重新声明。签名包括方法的名称、参数列表和返回类型。
class Rectangle
{
public:
int x, y;
int getArea() { return x * y; }
};
class Triangle : public Rectangle
{
public:
Triangle(int a, int b) { x = a; y = b; }
int getArea() { return x * y / 2; }
};
如果创建了一个三角形对象并调用了getArea
方法,那么三角形的方法版本将被调用。
Triangle t = Triangle(2,3);
t.getArea(); // 3 (2*3/2) calls Triangle’s version
然而,如果三角形被向上转换为矩形,那么矩形的版本将被调用。
Rectangle& r = t;
r.getArea(); // 6 (2*3) calls Rectangle’s version
这是因为重定义的方法只隐藏了继承的方法。这意味着 Triangle 的实现在类层次结构中被向下重定义到 Triangle 的任何子类,而不是向上重定义到基类。
覆盖派生成员
为了在类层次结构中向上重新定义一个方法,也就是所谓的覆盖,这个方法需要在基类中用virtual
修饰符来声明。此修饰符允许在派生类中重写该方法。
class Rectangle
{
public:
int x, y;
virtual int getArea() { return x * y; }
};
从 Rectangle 的接口调用getArea
方法将会调用 Triangle 的实现。
Rectangle& r = t;
r.getArea(); // 3 (2*3/2) calls Triangle’s version
C++11 添加了覆盖说明符,它表明一个方法旨在替换一个继承的方法。使用该说明符允许编译器检查是否存在具有相同签名的虚方法。这防止了意外创建新的虚方法的可能性。
virtual float getArea() override {} // error - no base class method to override
C++11 中引入的另一个说明符是 final。此说明符防止在派生类中重写虚方法。它还防止派生类使用相同的方法签名。
class Base
{
virtual void foo() final {}
}
class Derived
{
void foo() {} // error: Base::foo marked as final
}
final 说明符也可以应用于一个类,以防止任何类继承它。
class B final {}
class D : B {} // error: B marked as final
基类范围
通过键入类名后跟范围解析运算符,仍然可以从派生类中访问重新定义的方法。这被称为基类作用域 ,可用于允许访问类层次结构中任意深度的重定义方法。
class Triangle : public Rectangle
{
public:
Triangle(int a, int b) { x = a; y = b; }
int getArea() { return Rectangle::getArea() / 2; }
};
十六、访问级别
每个类成员都有一个可访问性级别,它决定了该成员在哪里可见。C++ 里有三种:public
、protected
、private
。类成员的默认访问级别是private
。要更改一段代码的访问级别,需要使用一个访问修饰符,后跟一个冒号。该标签后面的每个字段或方法都将具有指定的访问级别,直到设置了另一个访问级别或类声明结束。
class MyClass
{
int myPrivate;
public:
int myPublic;
void publicMethod();
};
私人访问
所有成员,不管它们的访问级别是什么,都可以在声明它们的类(封闭类)中访问。这是唯一可以访问私有成员的地方。
class MyClass
{
// Unrestricted access
public: int myPublic;
// Defining or derived class only
protected: int myProtected;
// Defining class only
private: int myPrivate;
void test()
{
myPublic = 0; // allowed
myProtected = 0; // allowed
myPrivate = 0; // allowed
}
};
受保护的访问
受保护的成员也可以从派生类内部访问,但不能从不相关的类访问。
class MyChild : public MyClass
{
void test()
{
myPublic = 0; // allowed
myProtected = 0; // allowed
myPrivate = 0; // inaccessible
}
};
公共访问
公共访问允许从代码中的任何地方进行不受限制的访问。
class OtherClass
{
void test(MyClass& c)
{
c.myPublic = 0; // allowed
c.myProtected = 0; // inaccessible
c.myPrivate = 0; // inaccessible
}
};
访问级别指南
作为一项准则,在选择访问级别时,通常最好使用最严格的级别。这是因为成员可以被访问的位置越多,它可以被错误访问的位置就越多,这使得代码更难调试。使用限制性访问级别还会使修改类变得更容易,而不会破坏使用该类的任何其他程序员的代码。
友元类和函数
通过将一个类声明为友元,可以允许该类访问另一个类的私有和受保护成员。这是通过使用friend
修改器来完成的。允许友元访问定义该友元的类中的所有成员,但不允许反过来。
class MyClass
{
int myPrivate;
// Give OtherClass access
friend class OtherClass;
};
class OtherClass
{
void test(MyClass c) { c.myPrivate = 0; } // allowed
};
一个全局函数也可以被声明为一个类的朋友,以获得相同级别的访问。
class MyClass
{
int myPrivate;
// Give myFriend access
friend void myFriend(MyClass c);
};
void myFriend(MyClass c) { c.myPrivate = 0; } // allowed
公共、受保护和私人继承
当一个类在 C++ 中被继承时,可以改变被继承成员的访问级别。公共继承允许所有成员保持其原始访问级别。受保护的继承减少了公共成员对受保护的。私有继承将所有继承的成员限制为私有访问。
class MyChild : private MyClass
{
// myPublic is private
// myProtected is private
// myPrivate is private
};
Private 是默认的继承级别,尽管 public 继承几乎总是被使用。
十七、静态
static
关键字用于创建只存在于一个副本中的类成员,该副本属于类本身。这些成员由该类的所有实例共享。这与实例(非静态)成员不同,后者是作为每个新对象的新副本创建的。
静态字段
静态字段(类字段)不能像实例字段一样在类内部初始化。相反,它必须在类声明之外定义。这种初始化只发生一次,静态字段将在应用程序的整个生命周期中保持初始化状态。
class MyCircle
{
public:
double r; // instance field (one per object)
static double pi; // static field (only one copy)
};
double MyCircle::pi = 3.14;
若要从类外部访问静态成员,请使用类名,后跟范围解析运算符和静态成员。这意味着不需要创建一个类的实例来访问它的静态成员。
int main()
{
double p = MyCircle::pi;
}
静态方法
除了字段之外,方法也可以被声明为static
,在这种情况下,它们也可以被调用,而不必定义类的实例。但是,因为静态方法不是任何实例的一部分,所以它不能使用实例成员。因此,只有当方法执行独立于任何实例变量的通用函数时,它们才应该被声明为static
。另一方面,与静态方法相反,实例方法可以使用静态和实例成员。
class MyCircle
{
public:
double r; // instance variable (one per object)
static double pi; // static variable (only one copy)
double getArea() { return pi * r * r; }
static double newArea(double a) { return pi * a * a; }
};
int main()
{
double a = MyCircle::newArea(1);
}
静态局部变量
函数内部的局部变量可以声明为static
,让函数记住变量。静态局部变量仅在执行第一次到达声明时初始化一次,然后在随后的每次执行中忽略该声明。
int myFunc()
{
static int count = 0; // holds # of calls to function
count++;
}
静态全局变量
最后一个可以应用static
关键字的地方是全局变量。这将把变量的可访问性限制为仅当前源文件,因此可以用来帮助避免命名冲突。
// Only visible within this source file
static int myGlobal;
十八、枚举类型
Enum 是用户定义的类型,由固定的命名常量列表组成。在下面的例子中,枚举类型称为Color
,包含三个常量:Red
、Green
和Blue
。
enum Color { Red, Green, Blue };
Color
类型可用于创建保存这些常量值之一的变量。
int main()
{
Color c = Red;
}
为了更加清楚,枚举常量可以以枚举名称为前缀。然而,这些常量总是未被划分,因此必须小心避免命名冲突。
Color c = Color::Red;
枚举示例
switch 语句提供了一个很好的例子,说明枚举何时有用。与使用普通常量相比,枚举的优势在于它允许程序员清楚地指定变量应该包含什么值。
switch(c)
{
case Red: break;
case Green: break;
case Blue: break;
}
枚举常量值
通常不需要知道常量所代表的基本值,但在某些情况下这可能是有用的。默认情况下,枚举列表中的第一个常量的值为零,每个后续常量的值都大一个。
enum Color
{
Red // 0
Green // 1
Blue // 2
};
这些默认值可以通过给常量赋值来覆盖。这些值可以计算,并且不必是唯一的。
enum Color
{
Red = 5, // 5
Green = Red, // 5
Blue = Green + 2 // 7
};
枚举转换
编译器可以隐式地将枚举常量转换为整数。但是,将整数转换回枚举变量需要显式强制转换,因为这种转换可能会分配一个不包含在枚举常量列表中的值。
int i = Red;
Color c = (Color)i;
枚举范围
不必全局声明枚举。它也可以作为类成员放在类中,或者局部放在函数中。
class MyClass
{
enum Color { Red, Green, Blue };
};
void myFunction()
{
enum Color { Red, Green, Blue };
}
强类型枚举
C++11 中引入了 enum 类,为常规 enum 提供了更安全的替代方法。这些新枚举的定义方式与常规枚举相同,只是增加了 class 关键字。
enum class Speed
{
Fast,
Normal,
Slow
};
对于新的枚举,指定的常量属于枚举类名的范围,而不是常规枚举的外部范围。因此,要访问枚举类常量,必须用枚举名对其进行限定。
Speed s = Speed::Fast;
标准没有定义常规枚举的基础整数类型,并且可能在不同的实现之间有所不同。相反,默认情况下,类枚举总是使用 int 类型。这个类型可以被重写为另一个整数类型,如下所示。
enum class MyEnum : unsigned short {};
枚举类的最后一个重要优势是它们的类型安全性。与常规枚举不同,枚举类是强类型的,因此不会隐式转换为整数类型。
if (s == Speed::Fast) {} // ok
if (s == 0) {} // error
十九、结构和联合
结构体
C++ 中的 struct 相当于一个类,只是 struct 的成员默认为公共访问,而不是类中的私有访问。按照惯例,使用结构而不是类来表示主要包含公共字段的简单数据结构。
struct Point
{
int x, y; // public
};
class Point
{
int x, y; // private
};
声明者列表
要声明结构的对象,可以使用普通的声明语法。
Point p, q; // object declarations
另一种常用于结构的语法是在定义结构时声明对象,方法是将对象名放在最后一个分号之前。这个位置被称为声明符列表,可以包含逗号分隔的声明符序列。
struct Point
{
int x, y;
} r, s; // object declarations
聚合初始化通常也与结构一起使用,因为这种语法上的快捷方式只适用于具有公共字段的简单聚合类型。对于支持 C++11 的编译器,统一的初始化语法是首选,因为它消除了聚合和非聚合类型初始化之间的区别。
int main()
{
// Aggregate initialization
Point p = { 2, 3 };
// Uniform initialization
Point q { 2, 3 };
}
联盟
虽然与 struct 相似,但联合类型的不同之处在于所有字段共享相同的内存位置。因此,联合的大小就是它包含的最大字段的大小。例如,在下面的例子中,这是一个 4 字节大的整数字段。
union Mix
{
char c; // 1 byte
short s; // 2 bytes
int i; // 4 bytes
} m;
这意味着联合类型一次只能用于存储一个值,因为更改一个字段将会覆盖其他字段的值。
int main()
{
m.c = 0xFF; // set first 8 bits
m.s = 0; // reset first 16 bits
}
除了有效的内存使用之外,联合的好处还在于它提供了查看同一内存位置的多种方式。例如,下面的 union 有三个数据成员,允许以多种方式访问同一个 4 字节组。
union Mix
{
char c[4]; // 4 bytes
struct { short hi, lo; } s; // 4 bytes
int i; // 4 bytes
} m;
整数字段将一次访问所有 4 个字节。使用 struct 一次可以查看 2 个字节,使用 char 数组可以单独引用每个字节。
int main()
{
m.i=0xFF00F00F; // 11111111 00000000 11110000 00001111
m.s.lo; // 11111111 00000000
m.s.hi; // 11110000 00001111
m.c[3]; // 11111111
m.c[2]; // 00000000
m.c[1]; // 11110000
m.c[0]; // 00001111}
匿名联盟
可以在没有名称的情况下声明联合类型。这被称为匿名联合,它定义了一个未命名的对象,该对象的成员可以从声明它的作用域直接访问。匿名联合不能包含方法或非公共成员。
int main()
{
union { short s; }; // defines an unnamed union object s = 15;
}
全局声明的匿名联合必须是静态的。
static union {};
二十、运算符重载
运算符重载允许在一个或两个操作数属于用户定义的类时重新定义和使用运算符。如果操作正确,这可以简化代码,并使用户定义的类型像基本类型一样易于使用。
运算符重载示例
在下面的例子中,有一个名为MyNum
的类,它有一个整数字段和一个用于设置该字段的构造器。该类还有一个加法方法,将两个MyNum
对象相加,并将结果作为一个新对象返回。
class MyNum
{
public:
int val;
MyNum(int i) : val(i) {}
MyNum add(MyNum &a)
{ return MyNum( val + a.val ); }
}
使用这种方法可以将两个MyNum
实例添加在一起。
MyNum a = MyNum(10), b = MyNum(5);
MyNum c = a.add(b);
二进制运算符重载
运算符重载的作用是简化语法,从而为类提供更直观的接口。要将add
方法转换为加法符号的重载,请将方法名替换为operator
关键字,后跟要重载的运算符。关键字和操作符之间的空格可以选择省略。
MyNum operator + (MyNum &a)
{ return MyNum( val + a.val ); }
由于该类现在重载了加法符号,因此该运算符可用于执行所需的计算。
MyNum c = a + b;
请记住,运算符只是调用实际方法的替代语法。
MyNum d = a.operator + (b);
一元运算符重载
加法是二元运算符,因为它需要两个操作数。第一个操作数是调用该方法的对象,第二个操作数是传递给该方法的对象。当重载一元操作符时,比如前缀增量(++
),不需要方法参数,因为这些操作符只影响调用它们的对象。
对于一元运算符,应该总是返回与对象类型相同的引用。这是因为当对一个对象使用一元运算符时,程序员希望结果返回同一个对象,而不仅仅是一个副本。另一方面,当使用二元运算符时,程序员希望返回结果的副本,因此应该使用按值返回。
MyNum& operator++() // ++ prefix
{ ++val; return *this; }
并非所有一元运算符都应通过引用返回。两个后缀操作符——后递增和后递减——应该改为按值返回,因为后缀操作应该在递增或递减发生之前返回对象的状态。请注意,后缀运算符指定了一个未使用的int
参数。此参数用于将它们与前缀运算符区分开来。
MyNum operator++(int) // postfix ++
{
MyNum t = MyNum(val);
++val;
return t;
}
过载运算符
C++ 允许重载语言中几乎所有的操作符。从下表中可以看出,大多数运算符都是二进制类型的。其中只有少数是一元的,有些特殊运算符不能归为这两种。还有一些运算符根本不能重载。
|
二元运算符
|
一元运算符
|
| --- | --- |
| + - * / % | + - !~ & * ++ - |
| = + = - = * = / = % = | 特殊操作员 |
| & = ^ = | = << = > > = | ()[ ]删除新的 |
| == != > < > = < = | 不可过载 |
| & | ^ << > > && || | 。。::?□sizo |
| –> – > , | |
二十一、自定义转换
自定义类型转换可以被定义为允许一个对象从另一种类型构造或者转换成另一种类型。在下面的例子中,有一个名为MyNum
的类,只有一个整数字段。使用转换构造器,可以将整数类型隐式转换为该对象的类型。
class MyNum
{
public:
int value;
};
隐式转换构造器
为了使这种类型转换生效,需要添加一个构造器,它接受所需类型的单个参数,在本例中是一个int
。
class MyNum
{
public:
int value;
MyNum(int i) { value = i; }
};
当一个整数被赋给一个对象MyNum
时,这个构造器将被隐式调用来执行类型转换。
MyNum A = 5; // implicit conversion
这意味着任何只接受一个参数的构造器既可以用于构造对象,也可以用于执行到该对象类型的隐式类型转换。
MyNum B = MyNum(5); // object construction
MyNum C(5); // object construction
这些转换不仅适用于特定的参数类型,还适用于可以隐式转换为该类型的任何类型。例如,char
可以被隐式转换成int
,因此也可以被隐式转换成MyNum
对象。
MyNum D = 'H'; // implicit conversion (char->int->MyNum)
显式转换构造器
为了帮助防止潜在的意外对象类型转换,可以禁用单参数构造器的第二次使用。然后应用explicit
构造器修饰符,它指定构造器只能用于对象构造,而不能用于类型转换。
class MyNum
{
public:
int value;
explicit MyNum(int i) { value = i; }
};
因此,必须使用显式构造器语法来创建新对象。
MyNum A = 5; // error
MyNum B(5); // allowed
MyNum C = MyNum(5); // allowed
转换运算符
自定义转换运算符允许从另一个方向指定转换:从对象的类型到另一个类型。然后使用 operator 关键字,后面是目标类型、一组括号和一个方法体。主体返回目标类型的值,在本例中为 int。
class MyNum
{
public:
int value;
operator int() { return value; }
};
当在 int 上下文中计算该类的对象时,将调用该转换运算符来执行类型转换。
MyNum A { 5 };
int i = A; // 5
显式转换运算符
C++11 标准在语言中增加了显式转换操作符。与显式构造器类似,包含 explicit 关键字可以防止隐式调用转换运算符。
class True
{
explicit operator bool() const {
return true;
}
};
上面的类提供了一个安全的 bool,通过 bool 转换操作符防止其对象被错误地用在数学上下文中。在下面的示例中,第一次比较导致编译错误,因为不能隐式调用 bool 转换运算符。第二个比较是允许的,因为转换运算符是通过类型转换显式调用的。
True a, b;
if (a == b) {} // error
if ((bool)a == (bool)b) {} // allowed
请记住,需要 bool 值的上下文(如 if 语句的条件)算作显式转换。
if (a) {} // allowed
二十二、名称空间
名称空间用于通过允许实体(如类和函数)被分组到一个单独的作用域下来避免命名冲突。在下面的例子中,有两个属于全局范围的类。因为两个类共享相同的名称和范围,所以代码不会被编译。
class Table {};
class Table {}; // error: class type redefinition
解决这个问题的一个方法是重命名其中一个冲突的类。另一种解决方案是将它们中的一个或两个放在一个不同的名称空间中,将它们放在一个名称空间块中。这些类属于不同的作用域,因此不会再引起命名冲突。
namespace furniture
{
class Table {};
}
namespace html
{
class Table {};
}
访问命名空间成员
要从名称空间外部访问该名称空间的成员,需要指定该成员的完全限定名。这意味着成员名必须以它所属的名称空间为前缀,后面跟着范围解析操作符。
int main()
{
furniture::Table fTable;
html::Table hTable;
}
嵌套命名空间
可以嵌套任意深度的名称空间,以进一步构建程序实体。
namespace furniture
{
namespace wood { class Table {}; }
}
当在另一个命名空间中使用嵌套的命名空间成员时,请确保使用完整的命名空间层次结构来限定它们。
furniture::wood::Table fTable;
导入命名空间
为了避免每次使用其成员时都必须指定命名空间,可以借助 using 声明将命名空间导入到全局或局部范围。该声明包括using namespace
关键字,后跟要导入的名称空间。它可以放置在本地或全局。在局部范围内,声明只在代码块的末尾有效,而在全局范围内,它将应用于声明之后的整个源文件。
using namespace html; // global namespace import
int main()
{
using namespace html; // local namespace import
}
请记住,将名称空间导入全局范围违背了使用名称空间的主要目的,即避免命名冲突。然而,这种冲突主要是使用几个独立开发的代码库的项目中的问题。
命名空间成员导入
如果您想避免键入完全限定名和导入整个名称空间,还有第三种选择。也就是说,只从名称空间导入所需的特定成员。这是通过用关键字using
一次声明一个成员,后跟要导入的完全限定的名称空间成员来实现的。
using html::Table; // import a single namespace member
命名空间别名
缩短完全限定名的另一种方法是创建名称空间别名。然后使用关键字namespace
,后跟一个别名,为其分配完全限定的名称空间。
namespace myAlias = furniture::wood; // namespace alias
然后可以用这个别名代替它所代表的名称空间限定符。
myAlias::Table fTable;
请注意,命名空间成员导入和命名空间别名都可以在全局和本地声明。
类型别名
也可以为类型创建别名。使用关键字typedef
后跟类型和别名来定义类型别名。
typedef my::name::MyClass MyType;
然后,该别名可以用作指定类型的同义词。
MyType t;
Typedef 不仅适用于现有类型,还可以包含用户定义类型的定义,如类、结构、联合或枚举。
typedef struct { int len; } Length;
Length a, b, c;
C++11 增加了 using 语句,为别名类型提供了更直观的语法。使用这种语法,关键字 using 后跟别名,然后分配类型。与 typedef 不同,using 语句还允许模板有别名。
using MyType = my::name::MyClass;
别名不常用,因为它们会混淆代码。但是,如果使用得当,类型别名可以简化冗长或易混淆的类型名。它们提供的另一个功能是从单一位置改变类型定义的能力。
包括命名空间成员
请记住,在 C++ 中,仅仅导入一个名称空间并不能提供对该名称空间中成员的访问。为了访问名称空间成员,原型也必须可用,例如通过使用适当的#
include
指令。
// Include input/output prototypes
#include <iostream>
// Import standard library namespace to global scope using namespace std;
二十三、常量
常量是一个变量,它的值一旦被赋值就不能改变。这允许编译器强制确保变量值不会在代码的任何地方被错误地更改。
常量变量
通过在数据类型之前或之后添加关键字可以将变量变成常量。这个修饰符意味着变量变成只读的,因此必须在声明的同时给它赋值。试图在其他地方更改该值会导致编译时错误。
const int var = 5;
int const var2 = 10; // alternative order
常量指针
说到指针,const
有两种用法。首先,指针可以保持不变,这意味着它不能改变指向另一个位置。
int myPointee;
int* const p = &myPointee; // pointer constant
其次,指针对象可以声明为常量。这意味着指向的变量不能通过这个指针修改。
const int* q = &var; // pointee constant
可以将指针和指针对象都声明为常量,使它们都是只读的。
const int* const r = &var; // pointer & pointee constant
注意常量变量不能被非常量指针指向。这可以防止程序员意外地使用指针重写常量变量。
int* s = &var; // error: const to non-const assignment
常量引用
引用可以像指针一样声明为常量。然而,因为永远不允许重新放置参考,所以将参考声明为const
是多余的。只有保护裁判不被改变才有意义。
const int& y = var; // referee constant
常量对象
正如变量、指针和引用一样,对象也可以声明为常量。以下面这个类为例。
class MyClass
{
public: int x;
void setX(int a) { x = a; }
};
不能将此类的常量对象重新分配给另一个实例。
一个对象的常量也会影响它的字段并阻止它们被改变。
const MyClass a, b;
a = b; // error: object is const
a.x = 10; // error: object field is const
常量方法
由于这最后一个限制,常量对象不能调用非常量方法,因为这样的方法被允许改变对象的字段。
a.setX(2); // error: cannot call non-const method
它们可能只调用常量方法,即在方法体之前用const
修饰符标记的方法。
int getX() const { return x; } // constant method
这个const
修饰符意味着该方法不允许修改对象的状态,因此可以被该类的常量对象安全地调用。更具体地说,const
修饰符适用于隐式传递给方法的this
指针。这有效地限制了方法修改对象的字段或调用类中的任何非常量方法。
常量返回类型和参数
除了使方法成为常量之外,返回类型和方法参数也可以成为只读的。例如,如果字段是通过引用而不是常量方法的值返回的,那么为了保持对象的常量性,将它作为常量返回是很重要的。不是所有的 C++ 编译器都能捕捉到这个微妙的错误。
const int& getX() const { return x; }
常量字段
类中的静态和实例字段都可以声明为常量。必须使用构造器初始化列表为常量实例字段赋值。这与初始化常规(非常量、非静态)字段的首选方式相同。
class MyClass
{
public:
int i;
const int c;
MyClass() : c(5), i(5) {}
}
常量静态字段必须在类声明之外定义,就像非常量静态字段一样。例外情况是当常量静态字段是整数数据类型时。这样的字段也可以在声明的同时在类中初始化。
class MyClass
{
public:
static int si;
const static double csd;
const static int csi = 5;
};
int MyClass::si = 1.23;
const double MyClass::csd = 1.23;
常量表达式
C++11 中引入了关键字 constexpr 来表示常量表达式。像 const 一样,它可以应用于变量,使它们成为常量,如果任何代码试图修改该值,就会导致编译错误。
constexpr int myConst = 5;
myConst = 3; // error: variable is const
与可能在运行时赋值的常量变量不同,常量表达式变量将在编译时计算。因此,在需要编译时常量的地方,比如在数组和枚举声明中,总是可以使用这样的变量。在 C++11 之前,这仅允许用于常量整型和枚举类型。
int myArray[myConst + 1];
函数和类构造器也可能被定义为常量表达式,这对于 const 是不允许的。在函数上使用 constexpr 会限制函数允许做的事情。简而言之,函数必须由单个 return 语句组成,并且只能引用其他 constexpr 函数和全局 constexpr 变量。C++14 放松了这些约束,允许 constexpr 函数包含其他可执行语句。
constexpr int getDefaultSize(int multiplier)
{
return 3 * multiplier;
}
只有当 constexpr 函数的参数是常量表达式时,才能保证在编译时对其返回值进行评估,并且返回值用于需要编译时常量的地方。
// Compile-time evaluation
int myArray[getDefaultSize(10)];
如果在没有常量参数的情况下调用函数,它会像普通函数一样在运行时返回值。
// Run-time call
int mul = 10;
int size = getDefaultSize(mul);
构造器可以用 constexpr 声明,以构造一个常量表达式对象。这样的构造器一定是平凡的。
class Circle
{
public:
int r;
constexpr Circle(int x) : r(x) {}
};
当用常量表达式参数调用时,结果将是编译时生成的具有只读字段的对象。如果有其他参数,它将像普通的构造器一样工作。
// Compile-time object
constexpr Circle c1(5);
// Run-time object
int x = 5;
Circle c2(x);
不变准则
一般来说,如果不需要修改变量,最好总是将变量声明为常量。这确保了变量不会在程序的任何地方被错误地改变,这反过来将有助于防止错误。通过让编译器有机会将常量表达式硬编码到编译后的程序中,还可以提高性能。这允许表达式在编译期间只计算一次,而不是每次程序运行时都计算。
二十四、预处理器
预处理器是一个文本替换工具,它在编译之前修改源代码。这种修改是根据源文件中包含的预处理器指令完成的。这些指令很容易与普通的编程代码区分开来,因为它们都以一个散列符号(#
)开始。它们必须始终作为一行中的第一个非空白字符出现,并且不以分号结束。下表显示了 C++ 中可用的预处理器指令及其功能。
|
管理的
|
描述
|
| --- | --- |
| #include
| 文件包括 |
| #define
| 宏定义 |
| #undef
| undefined(未定义宏) |
| #ifdef
| 如果宏定义 |
| #ifndef
| 如果未定义宏 |
| #if
| 如果 |
| #elif
| 否则如果 |
| #else
| 其他 |
| #endif
| 如果…就会结束 |
| #line
| 设置行号 |
| #error
| 中止编译 |
| #pragma
| 设置编译器选项 |
包括源文件
指令将一个文件的内容插入到当前的源文件中。它最常见的用途是包含头文件,包括用户定义的头文件和库头文件。库头文件用尖括号(< >)括起来。这告诉预处理器在默认目录中搜索头文件,该目录被配置为查找标准头文件。
#include <iostream> // search library directory
您为自己的程序创建的头文件用双引号("")括起来。然后,预处理器将在当前文件所在的目录中搜索该文件。如果没有找到头文件,预处理器将在标准头文件中搜索。
#include "MyFile.h" // search current, then default
双引号形式也可用于指定文件的绝对或相对路径。
#include "C:\MyFile.h" // absolute path
#include "..\MyFile.h" // relative path
规定
另一个重要的指令是#
define
,用来创建编译时常量,也叫宏。在这个指令之后,常量的名称被指定,后跟它将被替换的内容。
#define PI 3.14 // macro definition
预处理器将检查并改变这个常量的任何出现,以及它的定义中后面的任何内容,直到行尾。
float f = PI; // f = 3.14
按照惯例,常量应该用大写字母命名,每个单词用下划线隔开。这样,在阅读源代码时很容易发现它们。
不明确的
不应该使用#define
指令直接覆盖先前定义的宏。这样做会给编译器一个警告。为了改变一个宏,首先需要使用#undef
指令对其进行定义。尝试取消当前未定义的宏的定义不会生成警告。
#undef PI // undefine
#undef PI // allowed
预定义宏
编译器预定义了许多宏。为了区别于其他宏,它们的名字以两个下划线开始和结束。下表列出了这些标准宏。
|
管理的
|
描述
|
| --- | --- |
| __FILE__
| 当前文件的名称和路径。 |
| __LINE__
| 当前行号。 |
| __DATE__
| 以 MM DD YYYY 格式表示的编译日期。 |
| __TIME__
| 以 HH:MM:SS 格式表示的编译时间。 |
| __func__
| 当前函数的名称。在 C++11 中添加。 |
预定义宏的一个常见用途是提供调试信息。举个例子,下面的错误消息包括文件名和消息出现的行号。
cout << "Error in " << __FILE__ << " at line " << __LINE__;
宏功能
可以让宏接受参数。这允许他们定义编译时函数。例如,下面的宏函数给出了其参数的平方。
#define SQUARE(x) ((x)*(x))
调用宏函数就像调用一个普通的 C++ 函数一样。请记住,要使这种函数工作,参数必须在编译时已知。
int x = SQUARE(2); // 4
请注意宏定义中的额外括号,它们用于避免运算符优先级的问题。如果没有括号,下面的示例将给出不正确的结果,因为乘法将在加法之前执行。
#define SQUARE(x) x*x
int main(void) {
int x = SQUARE(1+1); // 1+1*1+1 = 3
}
要将一个宏函数分成几行,可以使用反斜杠字符。这将对标志预处理器指令结束的换行符进行转义。要做到这一点,反斜杠后面不能有任何空格。
#define MAX(a,b) \
a>b ? \
a:b
尽管宏功能强大,但它们往往会使代码更难阅读和调试。因此,只有在绝对必要的情况下才应该使用宏,并且应该尽量简短。常量变量、枚举类和 constexpr 函数等 C++ 代码通常比#define
指令更有效、更安全地完成相同的目标。
#define DEBUG 0
const bool DEBUG = 0;
#define FORWARD 1
#define STOP 0
#define BACKWARD -1
enum class DIR { FORWARD = 1, STOP = 0, BACKWARD = -1 };
#define MAX(a,b) a>b ? a:b
constexpr int MAX(int a, int b) { return a>b ? a:b; }
条件编译
如果满足特定条件,用于条件编译的指令可以包含或排除部分源代码。首先,有#if
和#endif
指令 ,它指定了一段代码,只有在#if
指令之后的条件为真时才会被包含。请注意,该条件必须计算为常量表达式。
#define DEBUG_LEVEL 3
#if DEBUG_LEVEL > 2
// ...
#endif
就像 C++ if 语句一样,可以包含任意数量的#elif
(else if)指令和一个最终的#else
指令。
#if DEBUG_LEVEL > 2
// ...
#elif DEBUG_LEVEL == 2
// ...
#else
// ...
#endif
条件编译还提供了一种有用的方法,可以出于测试目的临时注释掉大块代码。这通常不能用常规的多行注释来实现,因为它们不能嵌套。
#if 0
/* Removed from compilation */
#endif
如果已定义,则编译
有时,一段代码应该只在定义了某个宏的情况下才被编译,而不考虑它的值。为此,可以使用两个特殊操作符:defined
和!defined
(未定义)。
#define DEBUG
#if defined DEBUG
// ...
#elif !defined DEBUG
// ...
#endif
分别使用指令#ifdef
和 #ifndef
也可以达到同样的效果。例如, #ifdef
部分仅在指定的宏已经预先定义的情况下编译。请注意,即使没有给宏赋值,它也会被认为是已定义的。
#ifdef DEBUG
// ...
#endif
#ifndef DEBUG
// ...
#endif
错误
当遇到#error
指令时,编译中止。该指令对于确定某一行代码是否正在编译非常有用。它可以选择接受一个指定所生成的编译错误的描述的参数。
#error Compilation aborted
线条
一个不太常用的指令是#
line
,它可以改变编译过程中出现错误时显示的行号。遵循该指令,行号通常会为每一个连续的行增加一。该指令可以接受一个可选的字符串参数,该参数设置发生错误时将显示的文件名。
#line 5 "myapp.cpp"
杂注
最后一个标准指令是#
pragma
,即实用信息。此指令用于为编译器指定选项;因此,它们是特定于供应商的。举个例子,#pragma message
可以和许多编译器一起使用,向构建窗口输出一个字符串。该指令的另一个常见参数是warning
,它改变了编译器处理警告的方式。
// Show compiler message
#pragma message( "Hello Compiler" )
// Disable warning 4507
#pragma warning(disable : 4507)
属性
C++11 中引入了一种新的标准化语法,用于在源代码中提供编译器特定的信息,即所谓的属性。属性放在双方括号中,并且可以根据属性应用于任何代码实体。举个例子,在 C++14 中添加的一个标准属性是[[不推荐]],这表明不鼓励使用代码实体。
// Mark as deprecated
[[deprecated]] void foo() {}
每当使用这样的实体时,该属性允许编译器发出警告。此警告中可以包含一条消息,以描述不推荐使用该实体的原因。
[[deprecated("foo() is unsafe, use bar() instead")]]
void foo() {}
二十五、异常处理
异常处理允许程序员处理程序中可能出现的意外情况。
抛出异常
当函数遇到无法恢复的情况时,它可以生成一个异常来通知调用者函数已经失败。这是通过使用关键字后跟函数想要发送的信号来完成的。当到达该语句时,函数将停止执行,异常将传播到调用方,在调用方可以使用 try-catch 语句捕获异常。
nt divide(int x, int y)
{
if (y == 0) throw 0;
return x / y;
}
Try-catch 语句
try-catch 语句由一个 try 块和一个或多个 catch 子句组成,try 块包含可能导致异常的代码,catch 子句用于处理异常。在上面的例子中,抛出了一个整数,因此需要包含一个 catch 块来处理这种类型的异常。抛出的表达式将作为一个参数传递给这个异常处理程序,在这里它可以用来确定函数出了什么问题。注意,当异常被处理后,执行将在 try-catch 块之后继续运行,而不是在 throw 语句之后。
try {
divide(10,0);
}
catch(int& e) {
std::cout << "Error code: " << e;
}
异常处理程序可以通过值、引用或指针捕捉抛出的表达式。但是,应该避免按值捕捉,因为这会导致产生额外的副本。通过引用捕获通常是更可取的。如果 try 块中的代码可以抛出更多类型的异常,那么也需要添加更多的 catch 子句来处理它们。请记住,只有与抛出的表达式匹配的处理程序才会被执行。
catch(char& e) {
std::cout << "Error char: " << e;
}
为了捕捉所有类型的异常,可以使用省略号(...
)作为 catch 的参数。这个默认处理程序必须放在最后一个 catch 语句中,因为放在它后面的任何处理程序都不会被执行。
catch(...) { std::cout << "Error"; }
重新引发异常
如果一个异常处理程序不能从异常中恢复,它可以通过使用没有指定参数的关键字throw
被再次抛出。这将在调用方堆栈中向上传递异常,直到遇到另一个 try-catch 块。但是要小心,因为如果一个异常从未被捕获,程序将因运行时错误而终止。
int main()
{
try {
try { throw 0; }
catch(...) { throw; } // re-throw exception
}
catch(...) { throw; } // run-time error
}
异常说明
默认情况下,函数允许抛出任何类型的异常。为了指定函数可能抛出的异常类型,可以将throw
关键字追加到函数声明中。throw
关键字后面是逗号分隔的允许类型列表,如果有的话,用括号括起来。
void error1() {} // may throw any exceptions
void error2() throw(...) {} // may throw any exceptions
void error3() throw(int) {} // may only throw int
void error4() throw() {} // may not throw exceptions
这种异常规范与 Java 中使用的非常不同,总的来说,没有什么理由在 C++ 中指定异常。编译器不会以任何方式强制执行指定的异常,也不会因此而进行任何优化。
在 C++11 中不赞成使用throw
作为异常规范,它被 noexcept 说明符所取代。类似于throw()
,这个说明符表明函数不打算抛出任何异常。主要区别在于 noexcept 支持某些编译器优化,因为如果由于某种原因异常仍然发生,说明符允许程序终止而不展开调用堆栈。
void foo() noexcept {} // may not throw exceptions
异常类
如前所述,任何数据类型都可以在 C++ 中抛出。然而,标准库确实提供了一个名为exception
的基类,它是专门设计来声明要抛出的对象的。它在异常头文件中定义,位于std
名称空间下。如下所示,该类可以用一个字符串来构造,该字符串成为异常的描述。
#include <exception>
void make_error()
{
throw std::exception("My Error Description");
}
当捕捉到这个异常时,可以使用对象的函数what
来检索描述。
try { make_error(); }
catch (std::exception e) {
std::cout << e.what();
}
二十六、类型转换
将表达式从一种类型转换为另一种类型被称为类型转换。这可以隐式地或显式地完成。
隐式转换
当表达式需要转换成它的兼容类型之一时,编译器会自动执行隐式转换。例如,原始数据类型之间的任何转换都可以隐式完成。
long a = 5; // int implicitly converted to long
double b = a; // long implicitly converted to double
这些隐式原语转换可以进一步分为两种:晋升和降级。当表达式隐式转换为较大的类型时,发生升级;当表达式转换为较小的类型时,发生降级。因为降级会导致信息丢失,所以这些转换会在大多数编译器上生成警告。如果潜在的信息丢失是有意的,可以通过使用显式的强制转换来抑制警告。
// Promotion
long a = 5; // int promoted to long
double b = a; // long promoted to double
// Demotion
int c = 10.5; // warning: possible loss of data
bool d = c; // warning: possible loss of data
显式转换
第一种显式强制转换是从 C 继承而来的,通常称为 C 风格强制转换 。所需的数据类型只需放在需要转换的表达式左侧的括号中。
int c = (int)10.5; // double demoted to int
char d = (char)c; // int demoted to char
C++ 强制转换
C 样式的强制转换适用于基本数据类型之间的大多数转换。然而,当涉及到类和指针之间的转换时,它可能太强大了。为了更好地控制不同类型的转换,可能的 C++ 引入了四种新的类型转换,称为命名转换或新型转换。这些转换是:静态、重新解释、常量和动态转换。
static_cast<new_type> (expression)
reinterpret_cast<new_type> (expression)
const_cast<new_type> (expression)
dynamic_cast<new_type> (expression)
如上所述,它们的格式是在转换的名称后面加上用尖括号括起来的新类型,然后是用括号括起来的要转换的表达式。这些类型转换可以更精确地控制转换的执行方式,从而使编译器更容易捕捉转换错误。相比之下,C 风格的强制转换在一个操作中包括静态、重新解释和常量强制转换。因此,如果使用不当,该类型转换更有可能执行细微的转换错误。
静态投
静态强制转换在兼容类型之间执行转换。它类似于 C 风格的类型转换,但更具限制性。例如,C 风格的强制转换允许一个整数指针指向一个字符。
char c = 10; // 1 byte
int *p = (int*)&c; // 4 bytes
由于这会导致一个 4 字节的指针指向 1 字节的已分配内存,因此写入该指针将会导致运行时错误,或者会覆盖一些相邻的内存。
*p = 5; // run-time error: stack corruption
与 C 风格的强制转换不同,静态强制转换将允许编译器检查指针和指针对象数据类型是否兼容,这允许程序员在编译期间捕捉这种不正确的指针赋值。
int *q = static_cast<int*>(&c); // compile-time error
重新解释 Cast
为了强制进行指针转换,就像 C 风格的强制转换在后台进行的一样,可以使用 reinterpret 强制转换。
int *r = reinterpret_cast<int*>(&c); // forced conversion
这种强制转换处理某些不相关类型之间的转换,例如从一种指针类型转换到另一种不兼容的指针类型。它将简单地执行数据的二进制复制,而不改变底层的位模式。请注意,这种低级操作的结果是特定于系统的,因此不可移植。如果无法完全避免,则应谨慎使用。
Const Cast
第三种 C++ 强制转换是 const 强制转换。这个主要用于添加或删除变量的const
修饰符。
const int myConst = 5;
int *nonConst = const_cast<int*>(&a); // removes const
尽管 const cast 允许更改常量的值,但这样做仍然是无效的代码,可能会导致运行时错误。例如,如果常量位于只读存储器的某个部分,就会出现这种情况。
*nonConst = 10; // potential run-time error
相反,Const cast 主要用于有一个采用非常量指针参数的函数时,即使它不修改指针对象。
void print(int *p) { std::cout << *p; }
然后,可以通过使用常量强制转换将常量变量传递给该函数。
print(&myConst); // error: cannot convert
// const int* to int*
print(nonConst); // allowed
c 风格和新型造型
请记住,C 风格的强制转换也可以删除const
修饰符,但是同样,因为它在幕后进行这种转换,所以 C++ 强制转换更可取。使用 C++ 类型转换的另一个原因是它们比 C 风格类型转换更容易在源代码中找到。这很重要,因为造型错误可能很难发现。使用 C++ 强制转换的第三个原因是它们写起来不舒服。由于在许多情况下可以避免显式转换,所以这是有意为之,以便程序员可以寻找不同的解决方案。
动态投
第四个也是最后一个 C++ 强制转换是动态强制转换。这个只用于将对象指针和对象引用转换成继承层次结构中的其他指针或引用类型。通过执行运行时检查,确保指针指向目标类型的完整对象,这是确保所指向的对象可以被转换的唯一强制转换。为了使这种运行时检查成为可能,对象必须是多态的。也就是说,该类必须定义或继承至少一个虚函数。这是因为编译器只会为这些对象生成所需的运行时类型信息。
动态转换示例
在下面的例子中,使用动态转换将一个MyChild
指针转换成一个MyBase
指针。这个从派生到基的转换成功了,因为Child
对象包含了一个完整的Base
对象。
class MyBase { public: virtual void test() {} };
class MyChild : public MyBase {};
int main()
{
MyChild *child = new MyChild();
MyBase *base = dynamic_cast<MyBase*>(child); // ok
}
下一个例子试图将一个MyBase
指针转换成一个MyChild
指针。由于Base
对象不包含一个完整的Child
对象,这个指针转换将会失败。为了表明这一点,动态强制转换返回一个空指针。这为在运行时检查转换是否成功提供了一种便捷的方法。
MyBase *base = new MyBase();
MyChild *child = dynamic_cast<MyChild*>(base);
if (child == 0) std::cout << "Null pointer returned";
如果转换的是引用而不是指针,那么动态转换将会失败,抛出一个bad_cast
异常。这需要使用 try-catch 语句来处理。
#include <exception>
// ...
try { MyChild &child = dynamic_cast<MyChild&>(*base); }
catch(std::bad_cast &e)
{
std::cout << e.what(); // bad dynamic_cast
}
动态或静态转换
使用动态强制转换的优点是,它允许程序员在运行时检查转换是否成功。缺点是进行这种检查会带来性能开销。由于这个原因,在第一个例子中使用静态转换会更好,因为从派生到基的转换永远不会失败。
MyBase *base = static_cast<MyBase*>(child); // ok
但是,在第二个示例中,转换可能成功,也可能失败。如果MyBase
对象包含一个MyBase
实例,它将失败;如果它包含一个MyChild
实例,它将成功。在某些情况下,这可能直到运行时才知道。在这种情况下,动态转换是比静态转换更好的选择。
// Succeeds for a MyChild object
MyChild *child = dynamic_cast<MyChild*>(base);
如果使用静态转换而不是动态转换来执行从基到派生的转换,转换就不会失败。它会返回一个指向不完整对象的指针。取消引用这样的指针会导致运行时错误。
// Allowed, but invalid
MyChild *child = static_cast<MyChild*>(base);
// Incomplete MyChild object dereferenced
(*child);
二十七、模板
模板提供了一种使类、函数或变量处理不同数据类型的方法,而不必为每种类型重写代码。
功能模板
下面的示例显示了一个交换两个整数参数的函数。
void swap(int& a, int& b)
{
int tmp = a;
a = b;
b = tmp;
}
要将这个方法转换成可以处理任何类型的函数模板,第一步是在函数前添加一个模板参数声明。该声明包括关键字template
,后跟关键字class
和模板参数的名称,两者都用尖括号括起来。模板参数的名称可以是任何名称,但通常以大写字母 t 命名。
template<class T>
或者,可以用关键字typename
代替class
。在这种情况下,它们是等价的。
template<typename T>
创建函数模板的第二步是用模板参数替换将要成为泛型的数据类型。
template<class T>
void swap(T& a, T& b)
{
T tmp = a;
a = b;
b = tmp;
}
调用函数模板
函数模板现在完成了。要使用它,swap
可以像普通函数一样被调用,但是需要在函数参数前的尖括号中指定模板参数。在后台,编译器将实例化一个填充了这个模板参数的新函数,并且就是这个生成的函数将从这一行调用。
int a = 1, b = 2;
swap<int>(a,b); // calls int version of swap
每次用新类型调用函数模板时,编译器都会用模板实例化另一个函数。
bool c = true, d = false;
swap<bool>(c,d); // calls bool version of swap
在这个例子中,swap
函数模板也可以在没有指定模板参数的情况下被调用。这是因为编译器可以自动确定类型,因为函数模板的参数使用模板类型。然而,如果不是这种情况,或者如果需要强制编译器选择函数模板的特定实例化,那么模板参数将需要在尖括号内明确指定。
int e = 1, f = 2;
swap(e,f); // calls int version of swap
多个模板参数
通过将模板添加到尖括号之间,可以将模板定义为接受多个模板参数。
template<class T, class U>
void swap(T& a, U& b)
{
T tmp = a;
a = b;
b = tmp;
}
上例中的第二个模板参数允许用两个不同类型的参数调用swap
。
int main()
{
int a = 1;
long b = 2;
swap<int, long>(a,b);
}
课程模板
类模板允许类成员使用模板参数作为类型。它们的创建方式与函数模板相同。
template<class T>
class myBox
{
public:
T a, b;
};
与函数模板不同,类模板必须总是用显式指定的模板参数进行实例化。
myBox<int> box;
使用类模板时要记住的另一件事是,如果一个方法是在类模板之外定义的,那么该定义之前也必须有模板声明。
template<class T>
class myBox
{
public:
T a, b;
void swap();
};
template<class T>
void myBox<T>::swap()
{
T tmp = a;
a = b;
b = tmp;
}
注意模板参数包含在类名限定符之后的swap
模板函数定义中。这指定函数的模板参数与类的模板参数相同。
非类型参数
除了类型参数,模板还可以有类似函数的常规参数。例如,下面的int
模板参数用于指定数组的大小。
template<class T, int N>
class myBox
{
public:
T store[N];
};
当这个类模板被实例化时,必须包含一个类型和一个整数。
myBox<int, 5> box;
默认类型和值
可以为类模板参数指定默认值和类型。
template<class T = int, int N = 5>
要使用这些默认值,在实例化类模板时只需将尖括号留空即可。
myBox<> box;
请注意,默认模板参数不能在函数模板中使用。
类模板专门化
如果当特定类型作为模板参数传递时,需要为模板定义不同的实现,可以声明一个模板专门化 。例如,在下面的类模板中,有一个输出模板变量的值的print
方法。
#include <iostream>
template<class T>
class myBox
{
public:
T a;
void print() { std::cout << a; }
};
当模板参数是一个bool
时,该方法应该输出“真”或“假”而不是“1”或“0”。一种方法是创建一个类模板专门化 。然后在模板参数列表为空的地方创建类模板的重新实现。相反,一个bool
特殊化参数被放置在类模板的名称之后,并且在整个实现过程中,这个数据类型被用来代替模板参数。
template<>
class myBox<bool>
{
public:
bool a;
void print() { std::cout << (a ? "true" : "false"); }
};
当这个类模板被一个bool
模板类型实例化时,这个模板专门化将被使用,而不是标准的。
int main()
{
myBox<bool> box = { true };
box.print(); // "true"
}
请注意,标准模板和专用模板之间没有成员继承。整个类将不得不被重新定义。
函数模板专门化
由于上例中的模板之间只有一个函数不同,更好的替代方法是创建一个函数模板 专门化 。这种专门化看起来非常类似于类模板专门化,但是只应用于单个函数而不是整个类。
#include <iostream>
template<class T>
class myBox
{
public:
T a;
template<class T> void print() {
std::cout << a;
}
template<> void print<bool>() {
std::cout << (a ? "true" : "false");
}
};
这样,只有print
方法需要重新定义,而不是整个类。
int main()
{
myBox<bool> box = { true };
box.print<bool>(); // "true"
}
请注意,在调用专用函数时,必须指定模板参数。对于类模板专门化来说,情况并非如此。
可变模板
除了函数和类模板,C++14 允许变量模板化。这是使用常规模板语法实现的。
template<class T>
constexpr T pi = T(3.1415926535897932384626433L);
与 constexpr 说明符一起,该模板允许在编译时为给定类型计算变量值,而不必对该值进行类型强制转换。
int i = pi<int>; // 3
float f = pi<float>; // 3.14...
可变模板
C++11 允许模板定义接受可变数量的类型参数。该功能可用于替代变量函数。举例来说,考虑下面的变量函数,它返回传递给它的任意数量的整数的和。
#include <iostream>
#include <initializer_list>
using namespace std;
int sum(initializer_list<int> numbers)
{
int total = 0;
for(auto& i : numbers) { total += i; }
return total;
}
initializer_list 类型表明该函数接受一个用大括号括起来的列表作为它的参数,因此必须以这种方式调用该函数。
int main()
{
cout << sum( { 1, 2, 3 } ); // "6"
}
下一个例子把这个函数变成了一个可变的模板函数。这样的函数是递归遍历的,而不是迭代遍历的,所以一旦处理了第一个参数,函数就用剩下的参数调用自己。
使用省略号(...)运算符,后跟一个名称。这定义了所谓的参数包。参数包在这里被绑定到函数中的一个参数(...rest),然后解包到单独的参数(rest...)当函数递归调用自身时。
int sum() { return 0; } // end condition
template<class T0, class ... Ts>
decltype(auto) sum(T0 first, Ts ... rest)
{
return first + sum(rest ...);
}
这个可变模板函数可以作为一个常规函数调用,可以有任意数量的参数。与前面定义的变量函数不同,这个模板函数接受任何类型的参数。
int main()
{
cout << sum(1, 1.5, true); // "3.5"
}
二十八、头文件
当项目增长时,通常会将代码分割成不同的源文件。当这种情况发生时,接口和实现通常是分离的。接口放在头文件中,头文件通常与源文件同名,扩展名为. h。该头文件包含源文件实体的前向声明,这些实体需要可供项目中的其他编译单元访问。一个编译单元由一个源文件(cpp)加上任何包含的头文件(。h 或者。hpp)。
为什么要使用标题
C++ 要求所有东西在使用前都要声明。仅仅在同一个项目中编译源文件是不够的。例如,如果将一个函数放在 MyFunc.cpp 中,而同一个项目中名为 MyApp.cpp 的第二个文件试图调用它,编译器将报告找不到该函数。
// MyFunc.cpp
void myFunc() {}
// MyApp.cpp
int main()
{
myFunc(); // error: myFunc identifier not found
}
为了实现这个功能,函数的原型必须包含在 MyApp.cpp 中。
// MyApp.cpp
void myFunc(); // prototype
int main()
{
myFunc(); // ok
}
使用标题
如果将原型放在一个名为 MyFunc.h 的头文件中,并且通过使用#include
指令将这个头文件包含在 MyApp.cpp 中,这样会更方便。这样,如果对 MyFunc 做了任何更改,就不需要更新 MyApp.cpp 中的原型。此外,任何想要使用 MyFunc 中的共享代码的源文件都可以只包含这个头文件。
// MyFunc.h
void myFunc(); // prototype
// MyApp.cpp
#include "MyFunc.h"
标题中包含的内容
就编译器而言,头文件和源文件没有区别。这种区别只是概念上的。关键的思想是头文件应该包含实现文件的接口——也就是其他源文件需要使用的代码。这可能包括共享常量、宏和类型别名。
// MyApp.h - Interface
#define DEBUG 0
const double E = 2.72;
typedef unsigned long ulong;
如前所述,头文件可以包含源文件中定义的共享函数的原型。
void myFunc(); // prototype
此外,共享类通常在头文件中指定,而它们的方法在源文件中实现。
// MyApp.h class MyClass
{
public:
void myMethod();
};
// MyApp.cpp
void MyClass::myMethod() {}
与函数一样,在包含全局变量定义的编译单元之外的编译单元中引用全局变量之前,有必要转发声明全局变量。这是通过将共享变量放在头部并用关键字 extern 标记来实现的。该关键字指示变量在另一个编译单元中初始化。默认情况下,函数是外部的,所以函数原型不需要包含这个说明符。请记住,在一个程序中,全局变量和函数可以在外部声明多次,但它们只能定义一次。
// MyApp.h
extern int myGlobal;
// MyApp.cpp
int myGlobal = 0;
应该注意的是,不鼓励使用共享全局变量。这是因为程序越大,就越难跟踪哪些函数访问和修改这些变量。首选的方法是只在需要时将变量传递给函数,以最小化这些变量的范围。
标头不应包含任何可执行语句,但有两个例外。首先,如果一个共享类方法或全局函数被声明为inline
,那么这个函数必须在头文件中定义。否则,从另一个源文件调用内联函数将会产生一个无法解决的外部错误。请注意,内联修饰符取消了通常应用于代码实体的单个定义规则。
// MyApp.h
inline void inlineFunc() {}
class MyClass
{
public:
void inlineMethod() {}
};
第二个例外是共享模板。当遇到模板实例化时,编译器需要访问该模板的实现,以便创建一个填充了类型参数的实例。因此,模板的声明和实现通常都放在头文件中。
// MyApp.h
template<class T>
class MyTemp { /* ... */ }
// MyApp.cpp
MyTemp<int> o;
在许多编译单元中实例化同一类型的模板会导致编译器和链接器做大量的冗余工作。为了防止这种情况,C++11 引入了外部模板声明。标记为 extern 的模板实例化向编译器发出信号,不要在此编译单元中实例化模板。
// MyApp.cpp
MyTemp<int> b; // instantiation is done here
// MyFunc.cpp
extern MyTemp<int> a; // supress redundant instantiation
如果一个头文件需要其他头文件,通常也包括这些文件,使头文件独立。这确保了所需的一切都以正确的顺序包含在内,解决了每个需要头文件的源文件的潜在依赖性问题。
// MyApp.h
#include <cstddef.h> // include size_t
void mySize(std::size_t);
请注意,由于头文件主要包含声明,所以包含的任何额外头文件都不会影响程序的大小,尽管它们可能会降低编译速度。
包括警卫
使用头文件时要记住的一件重要事情是,一个共享代码实体只能定义一次。因此,多次包含同一个头文件可能会导致编译错误。防止这种情况的标准方法是使用所谓的包括 守卫 。include guard 是通过将头文件的开头包含在一个#ifndef
部分中来创建的,该部分检查特定于该头文件的宏。只有在未定义宏的情况下,文件才会被包含,然后再定义宏,这样可以有效地防止文件被再次包含。
// MyApp.h
#ifndef MYAPP_H
#define MYAPP_H
// ...
#endif // MYAPP_H