结构化编程
虽然 C++
是一种
本文作为随堂笔记和课后复习资料,主要介绍 C++
中的结构化编程的一些基本概念和技巧。至于一些细节,可以参考 C++
语言的相关资料。此外,应当随时查阅 CPP Reference 来精化理解。
基本内容
优先级和结合性
优先级指表达式在不同级计算时的优先计算顺序,而结合性指表达式在同级计算时的优先计算顺序。不同的优先级和结合性规则会导致不同的语法树,进而导致不同的计算结果。
C++
中的优先级规则如下(从上到下优先级依次降低):
- 括号的优先级最高,可以改变计算顺序;
- 一元运算符,如
++
、--
、*
、&
、+
、-
、!
、~
、sizeof
等等; - 算术运算符,这当中乘除模的优先级高于加减法;
- 移位运算符,如
<<
和>>
; - 关系运算符,如
>
、<
、>=
、<=
、==
、!=
; - 逻辑运算符,如
&&
和||
,其中&&
的优先级高于||
; - 三目运算符
- 赋值运算符,如
=
、+=
、-=
、*=
、/=
、%=
等; - 逗号运算符,如
a, b
,它的优先级最低。
在 C++
中,除了一元运算符、赋值运算符和三目运算符外,其他运算符都是左结合的。这意味着,如果有多个同级运算符,它们会从左到右依次计算。
小技巧
如果你不小心忘记了优先级和结合性规则,并且你的手边恰好有一台搭载了 Linux
或类 Unix
系统的电脑,那么你可以在终端输入下面的指令来查看优先级和结合性:
man operator
正常来说,它会显示一个简易的 C
语言符号优先级和结合性表。如果这也不行的话,那你总是可以相信括号(Lisp:这个我熟)。
类型转换
在 C++
中,我们经常需要对变量进行类型转换,或有时,编译器会自动帮我们进行处理。类型转换主要分为 coersion, or implicit conversion
(隐式转换)和 casting, or explicit conversion
(强制转换)两种。一般来说,当一个表达式中出现了两种不同的类型,并且用户没有额外干预,那么编译器就会自动进行隐式转换。例如,当我们将一个 int
类型的变量赋值给一个 double
类型的变量时,编译器会自动将 int
类型转换为 double
类型。而当我们在变量前加上 (type)
或者使用下文的 cast
时,我们就主动施加了强制转换。
在类型转换时,经常会出现溢出和精度损失的问题。一般来说,对于相同的数据存储模式,当我们把大的类型转换为小的类型时,就通常会出现溢出的问题,而当我们把小的类型转换为大的类型时,则一般没有问题。但也有特例:我们如果把 float
类型转换为 int
类型时,可能出现溢出或精度损失的问题;而把 int
类型转换为 float
类型时,可能出现精度损失的问题。一般来说,我们应当尽量避免这些问题,尤其是要及时识别可能的隐式转换。
恼人的隐式转换
隐式转换是 C++
中的一个特性,它可以让我们在一定程度上减少代码的冗余。但是,隐式转换也可能会导致一些很严重且很难发现和定位的错误。我们可以看几个小例子:
- 下面的代码为什么崩溃了?
int a[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (auto i = sizeof(a) / sizeof(a[0]); i >= 0; i--) {
a[i] = 0;
}
std::cout << a[0];
2
3
4
5
- 下面的代码为什么输出了
false
?
int a = 3.0;
double b = 1e16;
std::cout << (a + b - b == a ? "true" : "false");
2
3
容易体会到,要是经常让编译器给我们打工的话,可能会无意间埋下很多坑。
利用 cast
进行转换
这是 C++
特有的一类转换方式,主要分为以下四种:
static_cast <TYPE> (DATA)
:是最常用的类型转换之一,在编译时刻做表示形式级别的转换,适用于大多数显式类型转换。相对于直接类型转换,这种方式可以提供更强的类型检查。一般用于基础的类型转换(如int
和float
)等。const_cast <TYPE> (DATA)
:const_cast
是用于移除或添加const
修饰符的唯一合法方法。它可以将const
指针或引用的常量性去掉,使其可以被修改,或者反之。例如:c++int i = 42; const int *p_const = &i; // 指向常量的指针,不允许 p_const 修改 i int* p = const_cast<int*>(p_const); // 移除 const,允许 p 修改 i *p = 24; // 修改 i. 但假若 i 的类型为 const,会产生未定义行为
1
2
3
4注意: 如果对原本是常量的对象进行修改,结果是未定义行为,一般的编译器会在直接引用常量时填入原来的值,而在对指针解引用时返回新值。
const_cast
不是用来把const
对象转换为非const
对象的,而是用来去除指针的const
修饰符,以便进行修改和调整。dynamic_cast <TYPE> (DATA)
:主要用于类层次结构中,尤其是在有虚函数的多态情况下。它会在运行时进行类型检查,确保转换是安全的。它可以向上转换(将派生类对象转换为基类类型)和向下转换(将基类对象转换为派生类类型)。如果转换失败,指针类型会返回nullptr
,而引用类型会抛出std::bad_cast
异常。注意
const_cast
和dynamic_cast
类型TYPE
应当是引用或指针。考虑下面的一个简单的基类和派生类:
c++class F { public: virtual void foo() { cout << "Father" << endl; } }; class S1 : public F { public: int x = 1; void foo() { cout << "Son1" << endl; } }; class S2 : public F { public: int x = 2; void foo() { cout << "Son2" << endl; } };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24运行一下几组代码,可以看出
dynamic_cast
具有更强的类型检查:c++F* obj = new S1; obj->foo(); //Print 'Son1'. (If foo is non-virtual, print 'Father') static_cast<S1*>(obj)->foo(); //Print 'Son1' F* obj1 = new F; static_cast<S1*>(obj1)->foo(); //Unsafe cout << static_cast<S1 *>(obj1)->x << endl; //Undefined behavior F* obj2 = new S1; dynamic_cast<S1*>(obj2)->foo(); //OK. If F is not polymorphic (foo is non-virtual), compiling will fail. F* obj3 = new F; dynamic_cast<S1*>(obj3)->foo(); //Exception, `dynamic_cast<S1*>(obj3)` is nullptr.
1
2
3
4
5
6
7
8
9
10
11
12
13reinterpret_cast <TYPE> (DATA)
:它保持位的二进制序列不变,只是以新的类型解释变量,所有操作在编译期内完成,运行时不再提供检查。这是一种危险的转换,适用于底层操作和低级别编程。
表达式
下面是一些 C++
中引入的用于表达式中的常用关键字:
auto
关键字:可以使用auto
关键字来避免冗余的类型定义。但一定要时刻检测推导出的类型,防止之后出现隐式转换。decltype
关键字:用法是decltype (实体或表达式)
,推导出一个与括号中实体相同的类型,并将该类型作用于后面的对象。例如c++int i = 33; decltype(i) j = i * 2; //Type of j is int
1
2
同样也需要对推导出来的类型进行检查。
使用
range-for
使代码简洁:这是C++
中新增的语法糖。如果只是遍历容器中的元素,就可以使用range-for
语法来简化,如下c++vector<int> v = {1, 2, 3, 4, 5}; for (int i : v) { cout << i << endl; }
1
2
3
4
抽象数据类型
union
组合体
union
是一种特殊的数据结构,它允许在相同的内存位置存储不同的数据类型。union
的大小是其成员中最大的成员的大小。其核心思想就是共享内存。如果某一组合体最近被写入为某一个成员变量的类型,那么就只能按这一类型读取,否则会出现未定义行为。例如可以定义以下数据结构 S
:
union S
{
std::int32_t n; // 占用 4 字节
std::uint16_t s[2]; // 占用 4 字节
std::uint8_t c; // 占用 1 字节
}; // 整个联合体占用 4 字节
2
3
4
5
6
下面我们来看一个例子:
#include <cstdint>
#include <iostream>
int main()
{
S s = {0x12345678}; // 初始化首个成员,s.n 现在是活跃成员
// 于此点后,从 s.s 或 s.c 读取是未定义行为
std::cout << std::hex << "s.n = " << s.n << '\n';
s.s[0] = 0x0011; // s.s 现在是活跃成员
// 于此点后,从 s.n 或 s.c 读取是 UB 但大多数编译器都对其有定义
std::cout << "s.c 现在是 " << +s.c << '\n' // 11 或 00,取决于平台
<< "s.n 现在是 " << s.n << '\n'; // 12340011 或 00115678
}
2
3
4
5
6
7
8
9
10
11
12
这一程序的输出是:
s.n = 12345678
s.c 现在是 11
s.n 现在是 12340011
2
3
我们可以看出,利用 union
可以实现简单的多态,但可能产生 UB
。后面的 variant 是一种更加安全的组合体。
struct
结构体
struct
是一种用户自定义的数据类型,它可以包含不同类型的数据成员。和 class
一样,struct
也是类关键词。但 struct
的默认访问权限(成员访问和基类访问)是 public
,而 class
的默认访问权限是 private
。除此之外,struct
和 class
在 C++
中是相同的。自然 struct
可继承自其他类,亦可作为基类被继承。
结构体内存布局
在结构体对象内,其成员的地址(及位域分配单元的地址)按照成员定义的顺序递增。可以把指向结构体的指针转型为指向其首成员(或者若首成员为位域,则指向其分配单元)的指针。类似地,能转型指向结构体首成员的指针为指向整个结构体的指针。在任意两个成员间和最后的成员后可能存在无名的填充字节(亦称对齐,
数组
将一段相同数据类型的数据聚合到一段连续的地址空间中,并与
数组传递到函数中时,会发生类型转换。如 int a[8]
中的 a
传递到 void f(int a[])
当中时,函数中的 a
实际类型是 int* const
,即一个指针。
返回值
返回多个参数
使用引用参数:通过在参数列表中使用引用参数,可以允许在函数内部操作外部环境的变量,这样,函数对这些引用变量的影响可以传播到外部,这就相当于函数的输出。例如,下面的代码便展示了这种输出
void swap(int& i, int& j){
int k = i;
i = j;
j = k;
}
2
3
4
5
使用结构体作为返回值:可以先声明结构体,再在函数中创建这一结构体并返回即可。这一方式的缺点是声明的结构体大多数情况只是临时使用的,看起来过于冗余。
struct Pair{
int i;
int j;
}
Pair swap(int& i, int& j){
int k = i;
i = j;
j = k;
return {i, j};
}
2
3
4
5
6
7
8
9
10
11
使用元组来作为返回值:元组可以作为临时返回一组参数的选择,这一方式在 C++
中提高了编码的效率。元组既可以容纳两个变量,还可以容纳多个变量。
tuple<int, float> foo(){
return {1, 3.0f}
}
int main(){
auto r = foo();
std::cout << get<0>(r) << " " << get<1>(r) << std::endl;
auto [i, f] = r; //可以快速赋值给 i, f
std::cout<< i << " " << f << std::endl;
}
2
3
4
5
6
7
8
9
10
optional
:可选返回值 C++ 17
如果一个函数通常返回某类型的值,但遇到某些特殊情况不需要返回或不知返回何值,此时就可以使用 optional
来进行返回值的封装。当不返回有效值时,我们返回一个 nullopt
,然后可以在函数外部接收并判断。
#include<optional>
std::optional<string> getNameByID(const vector<std::pair<int, string>>& v, int id){
for(auto e : v){
if(e.first == id){
return e.second;
}
}
return std::nullopt;
}
int main(){
using namespace std;
vector<std::pair<int, string>> students{{1, "AAA"}, {2, "BBB"}, {3, "CCC"}};
int student_id;
cin >> student_id;
auto id = getNameByID(students, student_id);
if(id.has_value()){
cout << id.value() << endl;
}else{
cout << "NOT FOUND!" << endl;
}
cout << id.value_or("NOT FOUND") << endl; // we can simplify output to one statement.
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
variant
:类型安全组合体 C++ 17
即 type-safe union
。union
的问题是可能当中的内容与解释的类型是不相符的,这会导致一系列安全性问题。而 variant
可以提供更严格的类型检查,在编译时或运行中阻止不正确的访问。
std::variant<int, float, string> v;
v = "abc";
cout << v.index() << " " << std::get<string>(v) << endl; // string, OK
v = 100;
cout << v.index() << " " << std::get<0>(v) << endl; // get the first child, which is int, OK
v = 2.3f;
cout << v.index() << " " << std::get<float>(v) << endl; // float, OK
cout << v.index() << " " << std::get<double>(v) << endl; // double, not found in type list, compile ERROR
cout << v.index() << " " << std::get<int>(v) << endl; // int, not the corresponding type, runtime exception
float* pf = std::get_if<float>(&v); // we can use guard pointer to judge.
if(pf != nullptr){ // float, OK
cout << v.index() << " " << std::get<float>(v) << endl;
}else{ // not float, invalid!
cout << "Invalid" << endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
any
:任何类型的容器 C++ 17
如果不能将返回值显式地表现出来,可以直接用 any
来进行封装。any
相当于是一个篮子,它可以接受任何类型的返回值。它相当于是更安全的 void*
。
any input(){
int i;
cin >> i;
switch(i){
case 0:
return 11;
break;
case 1:
return 3.14;
break;
default:
return string("Hello, world!");
break;
}
}
int main(){
any aa;
aa = input();
if(aa.type() == typeid(int)){
// do something...
}else if(aa.type() == typeid(double)){
// do something...
}else{
// do something...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
指针
指针让我们可以动态地调配内存中的空间,还可以让我们在一定范围内直接操作内存。可以用指针访问变量、数组中的元素、甚至代码(函数指针)。我们还经常使用指针进行传参。指针传参的效率很高,并且可以对外部变量进行修改。
这一节我们不介绍指针的基础知识,指针的最基础知识可以参考 C
语言课程资料。
常量指针、指针常量......师傅别念了!
这一块的确有些绕,初学时需要仔细多读几遍。一般来说,常量指针的意思是指向常量的指针,但它本身可以改变,指向其他变量;而指针常量的意思是该指针是常量,一经初始化后不可改变,但指向的变量可以不是常量。它们的定义如下例:
const int *p1;//常量指针
int *const p2 = &a;//指针常量
2
还有一种指向常量的指针常量,它的定义为 const int *const p3 = &b
。它完全只读,不可修改指向也不可修改指向的变量。
你可能会想到,指针是可以指向指针的,因此可以形成多级指针链,可以对每一级的指针进行常量限定。这一部分比较复杂,可以参考 限定性分解。
指针和数组
由于数组是连续的一块内存区域,因此我们可以很方便地使用指针对其进行操作。
对于一维数组 a[]
,我们知道,其数组名 a
实际上代表了数组首元素的地址,即 a = &a[0]
;反过来说,也就是 *a = a[0]
。同理,我们可以使用指针的偏移来访问数组中其他的元素,如 *(a+i) = a[i]
。
对于多维数组,我们可以把它看作递归构造的一维数组。例如,对于二维数组 a[m][n]
,我们有 a[0] = &a[0][0]
;反过来,我们有 *a[0]=a[0][0]
。因此我们可以发现一个简单的规律,即数组加取地址符 &
可以去掉一层括号,而加解除引用符 *
可以增加一层括号。此外,由于数组的连续性,我们可以发现 a[i][j] = *(a+i*n+j)
。因此我们可以发现,无论数组的维度如何,它总是线性紧密排列的。
如果我们声明一个指针 int (*p)[2]
,这就是一个指向 int [2]
类型的数组指针,它指向一个长度为 int
数组。如果我们假设 int a[6][2]
,那么显然 a[0]
是一个满足条件的数组,我们可以使用 p = &a[0]
来指向它。而由于 &a[0] = a
,所以我们可以让它直接指向 a
。
int (*p)[2]
和 int *p[2]
?
这是两种比较容易混淆的写法。int *p[2]
的类型是 int *[2]
,指长度为 int
类型的数组;int (*p)[2]
指指向一个长度为 int
数组的指针。前者是指针数组,后者是指针,两者有本质上的区别。
指针也可以指向一个多维数组。例如 int (*p)[2][3]
就指向了 int [2][3]
这一个 int
数组。合起来,可以定义
如果理解了上述规则,可以尝试思考:
char (*(*a[10]))[3]
代表的是什么?- 对于
int a[2][3][4]
,如何定义一个长度为 的指针数组,使其分别指向a[0]
和a[1]
这两个 二维数组?
请思考后,将鼠标移动到下一行查看答案。
1. 一个长度为 10 的指针数组,数组里每个指针指向一个 [指向长度为 3 的 char 数组的] 指针。
2. int (*ptr[2])[3][4] = {&a[0], &a[1]}; // 注意取地址符!
2
3
不同维数的转换
对于不同维数的变量,它们的类型不相同。因此,我们在赋值、传参时可能需要对其进行(强制)转换。强制转换时,我们经常只改变指针的类型,而不改变对应内存的布局。
下面是一个传参数的例子:
void showA(int a[][2][3], int n);
void showB(int a[][2], int n);
void showC(int a[], int n);
int b[12]={0};
showA((int(*) [2][3]),2)
showB((int(*) [2])b, 6);
showC(b, 12);
2
3
4
5
6
7
8
动态变量
在 C++
中,除了从 C
继承过来的 malloc
和 free
,还可以用 new
和 delete
来生成和回收动态变量。
int *p = new int[8]; // int *p = (int *)malloc(sizeof(int) * 8)
delete[] p; //free(p)
2
当 new
时使用了 []
,那么需要在释放时也应当使用。对于申请得来的指针,切勿轻易移动它,防止在 delete
时出现问题。
new
和 malloc
最大的区别是,当生成的是类的实例时,new
会自动调用类的构造函数(如果生成的是数组,则逐个调用);delete
也同理,它会自动调用类的析构函数,而 delete []
则可以逐个调用数组的析构函数。
使用指针需要时刻注意严禁出现空闲指针,并谨防内存泄露;
智能指针 unique_ptr
为了减少泄漏的风险,C++
引入了智能指针 unique_ptr
,如下
void old_use(Args* a){
auto q = new Blob(a);
//...
if(foo) throw Bad(); // q leak...
if(bar) return; // also leak...
delete q; // Don't forget!
}
void newer_use(Args a){
auto p = unique_ptr<Blob>(new Blob(a));
if(foo) throw Bad(); // won't leak
if(bar) return; // won't leak
}
2
3
4
5
6
7
8
9
10
11
12
13
14
unique_ptr
可以在对象生命期结束时(例如,超出作用域时)自动调用析构函数,这样就防止了内存泄露。我们还有其他两种智能指针:shared_ptr
和 weak_ptr
。
智能指针 share_ptr
shared_ptr
是一种通过指针保持对象共享所有权的智能指针,多个 shared_ptr
对象可持有同一对象。用 shared_ptr
来初始化另一个 shared_ptr
,即可让两个指针指向同一个对象。当最后一个持有对象的 shared_ptr
被销毁或被赋值为另一指针时,对象会被销毁。
std::shared_ptr<int> p1(new int(42));
//或者这样写: std::shared_ptr<int> p1 = std::make_shared<int>(42);
std::shared_ptr<int> p2 = p1; // p1 and p2 point to the same object
std::cout << p1.use_count() << std::endl; // 2
p1.reset(); // p1 release the object
std::cout << p2.use_count() << std::endl; // 1
2
3
4
5
6
使用 .get()
可以读取原始指针,注意,这一原始指针不能直接赋给 shared_ptr
。使用 .use_count()
来查看当前对象的引用计数。
小技巧
有时,我们可以使用 shared_ptr
来将某些对象的生命周期延长(例如,可以将它的生命周期延长到作用域外),以避免过早释放。
继承
使用 make_share
或 make_unique
时,如果我们希望保存一个基类的指针,但是实际上指向派生类对象,应当这样写:
std::shared_ptr<Base> p = std::make_shared<Derived>(Derived(...)); //...为派生类的构造函数参数
make_shared
或 make_unique
的参数应当是派生类。如果是一个基类,那么返回的指针也是基类指针,而不是派生类指针;此时会产生切片问题。
智能指针 weak_ptr
weak_ptr
持有被 shared_ptr
管理的对象的非拥有性「弱」引用——当某个对象只有存在时才需要被访问,且随时可能被他人删除时,可以使用 weak_ptr
来跟踪该对象。在访问引用的对象前必须先转换为 shared_ptr
。如果此时销毁了原始 shared_ptr
,则对象的生命周期将被延长,直到临时 shared_ptr
也被销毁为止。
NULL
or nullptr
?
空指针是一个很常用的概念。最早的 ANSI C
利用宏定义来说明空指针 #define NULL ((void *)0)
。这一定义方式欠佳,因为它使得 void*
可以被赋值到其他指针,造成了类型的转换的不严格。例如,char* s = NULL
是被允许的,这就导致了可以用 char*
来解释 void*
。
而 C++
对这一宏定义做了改进:#define NULL 0
。但这一方式也不好,它在某些多态情景下会失效。如,无法区分 void foo(int)
和 void foo(char*)
。
因此,C++
又引入了 nullptr
,将其作为推荐的空指针写法。它会被特殊处理,而不会简单地以宏定义的形式被替换。
函数
函数是一小段代码的集合,它可以被调用,也可以被传递。函数在使用前必须被定义或声明。此外,与其他语言不同,函数的定义不允许嵌套。
声明和定义的区别
对于函数:
- 声明指的是向编译器表明某个函数或变量的存在,但不具体描述其实现细节,也称为函数的原型。声明时,不需要指定函数参数的名称,只需要指定参数的类型,如
int foo(int, int)
; - 定义则包含了函数的实现,即具体代码。
我们可以将函数的声明比作变量的声明,函数的定义比作变量的初始化或赋值。
函数之间通过互相调用来实现功能协作。函数的大致执行机制如下:
- 建立被调用函数的栈空间
- 进行参数传递
- 保存当前函数现场:函数的栈基址(ebp)和栈顶(esp)
- 进入调用函数
我们可以看一个简单的小例子,这里使用一种简单的伪代码作为 IR:
void foo(){
int x;
int y;
x = 1;
y = 2;
swap(&x, &y);
}
2
3
4
5
6
7
SP = SP - 8
M[SP + 4] = 1
M[SP] = 2
R1 = SP
R2 = SP + 4
SP = SP - 8
M[SP + 4] = R1
M[SP] = R2
CALL <swap>
SP = SP + 8
SP = SP + 8
RET
2
3
4
5
6
7
8
9
10
11
12
+--foo--+ // foo 函数栈帧 (栈底)
| x = 1 |
| y = 2 |
| &y |
| &x |
+--swap-+ // swap 函数栈帧
| ... |
2
3
4
5
6
7
对于不同的参数传递方式,函数保存现场和进入被调用函数的方式会不相同。一般来说,有按值传递和按地址传递两种方式。按值传递时,函数会在进入之前将参数的值拷贝到栈空间中;而按地址传递时,函数会将参数的地址传递给被调用函数,被调用函数可以直接访问这一地址。
最后,我们再详细地说明一下函数执行的过程。
函数执行过程
- 将参数或参数的地址压栈;
- 将返回地址压栈,保持调用者的现场(
ebp
和esp
); - 跳转到函数的入口地址;
- 设置新函数的
ebp
; - 分配局部变量的空间;
- 执行函数体;
- 释放局部变量的空间;
- 加载调用者的
ebp
,跳转到返回地址,弹出返回地址; - 重新设置调用者的
esp
; - 返回调用者,继续执行。
函数指针
函数指针是指向函数的指针,可以用来调用函数。函数指针的声明方式为 返回类型 (*指针名)(参数列表)
。函数指针的使用方法可以是先定义一个函数指针,然后将函数的地址赋给它,最后通过函数指针调用函数;此外,还可以将函数指针作为参数传递给函数。
一个函数指针的例子如下:
int add(int a, int b){
return a + b;
}
int sub(int a, int b){
return a - b;
}
int main(){
int (*p)(int, int);
p = add;
cout << p(1, 2) << endl;
p = sub;
cout << p(1, 2) << endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们可以看出,函数指针实现了多态的效果。
你认识下面的定义吗?
函数指针的定义十分灵活,因此有时会变得很复杂。为了不妥协于全盘相信 auto
关键字,我们还是应当试图理解一些比较复杂的函数指针定义(也可以锻炼一下思维!)。下面就是一些例子:
int* (*funcPtr)(double);
int (*p[2])(int, int);
int (*sp(double))(int, int);
2
3
他们的定义分别是:
请思考后,将鼠标移动到下一行查看答案。
1. 一个指向返回值为 int*,参数为 double 的函数的指针 funcPtr;
2. 一个长度为 2 的指针数组 p,其中每个元素都是一个指向返回值为 int,参数为 (int, int) 的函数的指针;
3. 一个返回值为指向返回值为 int,参数为 (int, int) 的函数的指针的函数 sp。注意!这是函数的声明而不是指针声明!
2
3
4
在实际的编程中,当你感到为函数指针的定义操劳过度时,不妨使用 typedef
来简化定义。或者——直接使用 auto
。当然,如果你是最强大脑并且没人看你的代码的话,可以直接使用这种定义方式。
函数的重载
函数重载是指在同一个作用域内,可以定义多个同名函数,但是它们的参数列表不同。函数重载的目的是为了提高代码的可读性和可维护性。函数重载的规则如下:
- 函数名相同,参数列表不同;
- 参数列表不同指参数的个数、类型或顺序不同(不考虑参数名);
- 返回值类型不在重载的考虑范围内;
当没有找到与调用参数列表完全匹配的函数时,编译器会尝试进行隐式类型转换。如果找到了匹配的函数,就会调用这个函数。如果找到了多个匹配的函数,编译器会报错;如果尝试所有可能的隐式类型转换都无法找到匹配的函数,编译器也会报错。
多文件程序组织
使用多文件组织程序可以使得程序更加清晰,符合模块化的思想,更加适合大型程序的开发。C++
提供了一系列机制来保证文件有序高效地被组织起来。下面我们来介绍一小部分。
extern
和 static
关键字
C++
提供了链接器,可以让我们把多个文件中的函数和变量链接在一起。可是,C++
的编译阶段是先于链接阶段的,因此在编译时,编译器只能看到本文件中定义的函数和变量,并不知道其他文件中的函数和变量的具体位置。(这一设计某一方面是为了性能的考量)这时,如果简单地使用其他文件中的函数和变量,编译器就会报错。
关于 C++
的编译和链接过程
如果对于 C++
的编译和链接过程不太了解,可以参考 CPP Reference 翻译阶段。
因此,我们需要对来自外部的函数和变量提前进行标记,好让编译器知道这些函数和变量是外部的,并预留相应的“插槽”。这就是 extern
关键字的作用。
反过来,对于有些函数和变量,我们不希望它们被其他文件访问或使用,这时我们可以使用 static
关键字。static
关键字可以用于函数和变量,它的作用是限制函数和变量的作用域,使得它们只能在当前文件中使用。对于当前文件,static
关键字作用的变量还会将作用域扩大到全局,相当于在 main
函数外定义。
在 static
关键字还有其他的作用。
头文件
头文件是用于声明程序中的函数、类、变量和其他实体的代码集合。它们是 C++ 编程语言中的重要组成部分,用于将程序的结构和实现分开,简化多文件组织逻辑,提高代码的重用性、可读性和可维护性。
头文件通常包括常量定义 const
、类型定义 typedef
、预处理指令、(内联)函数声明等。一般来说,头文件中不包含函数的具体实现,而只包含函数的声明,除非我们期待编译器为我们进行内联操作。
头文件可以被用户自定义,也可以是系统提供的。在 C++
中,系统提供的头文件一般没有扩展名,而用户自定义的头文件一般以 .h
或 .hpp
结尾。在使用头文件时,我们使用 #include
指令将其包含进来,这就是一条预处理指令。如果头文件在当前目录下,我们使用 #include "filename"
;如果头文件在系统目录下,则使用 #include <filename>
。当这一指令被编译器看到时,它会将头文件的内容直接复制到当前文件中。
防止循环...
此外,我们还有一条常用的指令 #ifndef
,它可以防止头文件被重复包含。比如,头文件 a.h
的开头使用了 #include "b.h"
,而 b.h
的开头又使用了 #include "a.h"
,这样就会导致编译时无限循环(读者不妨动手试试)。通常来讲,这会让编译器在预处理时发生问题,可能产生以下错误输出:
......
In file included from /home/user1/Desktop/code/b.h:10:
In file included from /home/user1/Desktop/code/a.h:10:
In file included from /home/user1/Desktop/code/b.h:10:
In file included from /home/user1/Desktop/code/a.h:10:
In file included from /home/user1/Desktop/code/b.h:10:
In file included from /home/user1/Desktop/code/a.h:10:
In file included from /home/user1/Desktop/code/b.h:10:
/home/user1/Desktop/code/a.h:10:10: error: #include nested too deeply
#include "b.h"
^
1 error generated.
ninja: build stopped: subcommand failed.
2
3
4
5
6
7
8
9
10
11
12
13
因此,我们一般使用 #ifndef
和 #define
宏来防止这种情况。一个范式是:
#ifndef A_H
#define A_H
// your code here
#endif
2
3
4
5
6
这样,当 a.h
被重复包含时,A_H
宏已经被定义,就不会再次包含了。我们可以使用文件名的大写形式来定义这一宏,这样可以避免宏名冲突。
namespace
命名空间
在我们第一次写 C++
的 Hello World!
的程序时,我们就已经接触到了 namespace
(想想 using
指令或 std::
)。命名空间主要是解决了不同组织代码命名冲突的问题。设想两个组织在函数库中都提供了某个名为 fseek
的函数,如果我们凑巧同时使用了他们的两个库,那么很容易会产生冲突。命名空间旨在解决函数间命名的冲突并提高灵活性,还可以替代 static
来更个性化地约束作用域。
我们可以用如下的形式来定义一个命名空间:
namespace my_namespace{
int a;
void foo();
void bar();
}
2
3
4
5
在使用时,有以下几种方式:
my_namespace::a = 1;
my_namespace::foo();
2
using namespace my_namespace;
a = 1;
foo();
2
3
using my_namespace::a;
using my_namespace::foo;
a = 1;
foo();
2
3
4
当使用的函数和变量名不多时,可以不用 using
关键字来导入。否则,可以考虑使用 using
关键字导入来减少冗余。需要注意的是,如果使用两次 using-directive
的话,可能会引发问题。因此,尽量使用 using-declaration
方式。
内联函数
内联函数是对编译器的一种指示,它告诉编译器在所有的函数调用处直接展开函数体,而不进行函数调用的压栈操作等,这种技术也被称为内联展开。内联展开的好处是可以减少函数调用的开销,提高程序的执行效率,但内联函数的缺点是会增加代码可执行文件的体积。通过在函数定义或声明时使用 inline
关键字前缀,可以将函数声明为内联函数。
需要注意的是,内联函数只是对编译器的一种指示,编译器不一定会采纳。一般来说,编译器会根据函数的复杂度和调用频率来决定是否内联展开。如果函数体过于复杂,或包含递归等,编译器可能会拒绝内联展开。反过来说,如果函数体过于简单,编译器也可能会自动进行内联展开来优化(这一可能性与编译器版本和优化等级有关)。因此,如果要在函数定义时使用 inline
关键字,需要保证函数尽可能简短。
内联函数使用时必须保证其定义在翻译单元中可达。因此,在多文件组织中,内联函数的定义通常放在头文件中,这样可以保证在每个文件中都能看到内联函数的定义,从而进行内联展开。
预编译指令
预编译指令是在编译之前对源代码进行处理的指令。它以 #
开头,主要的作用包括对代码进行增删改的操作,以及向编译器传递信息。我们前文提到的 #include
、#define
、#ifndef
等都是预编译指令。下面我们就来详细地总结一些常用的预处理指令。
指令 | 用法 | 作用 |
---|---|---|
#define | #define a <str> | 定义宏常量,编译器会将所有出现 a 的地方替换为 <str> (全字匹配,区分大小写)。<str> 是可选的。 |
#undef | #undef a | 取消宏定义 a 。 |
#ifdef | #ifdef a | 如果宏 a 已经定义,则执行后面的代码。 |
#ifndef | #ifndef a | 如果宏 a 未定义,则执行后面的代码。 |
#if | #if a > b | 如果 a > b ,则执行后面的代码。 |
#pragma | #pragma option | 编译器指令,用于设置编译器的一些选项。例如,可以用该指令开启或关闭某些警告;还可以设置优化等级,如 #pragma optimize(3) 即 O3 优化。 |
预编译指令是在编译之前执行的,因此它对语言的“理解”是很粗浅的。在现代的 C++
中,我们尽量避免使用宏定义,而是应当尝试使用 C++
提供的 const
、typedef
、template
、namespace
和 inline
来更好地对源代码进行操作。