0%

CPP设计-寸步难行

一、二义性和无义性

我们都知道程序是接受不了二义性的,当一条语句可以有两个执行结果的时候,那么程序就会接受不了。但是我们常常忽略“无义性”,这是我取得概念,意思是这条语句的执行结果是不确定的,这种不确定不是说结果不确定,比如 C 里面局部变量刚声明的时候值也是不确定的,但是没关系,而是说执行的规则是不确定的,那么就是不可以的。

但是由于 CPP 高度的自由性,所以他希望所有的规则都是使用者来制定,但是这种自定义规则制定前,也必然是存在一定的规则的,但是这个规则需要我们自己去了解。


二、声明、定义、初始化

2.1 概念

首先说明声明的意思,声明就是告诉 CPP 这个东西的名字和类型。相当于在符号表上“登记”了一下。但是这个东西到底是怎么样的,其实是没有规定的。

而对于定义,就是说告诉 CPP 这个东西的具体是怎样的,它的内存空间必须要有(声明没必要有),内存空间里的数据是怎样的,也必须要有一定的规则(里面的东西不一定是固定的,但是规则一定要有)。

2.2 函数的声明与定义

其实上面这两个概念很好理解,尤其是“这个东西”是函数的时候,我们区分这两个概念很容易:

// function declaration
int sum(int, int);

// function defination
int sum(int a, int b)
{
	return a + b;
}

2.3 变量的声明与定义

但是对于变量,情况会变得很复杂。即在 CPP 中。只有 extern 关键字可以完成变量的“只声明不定义”操作。也就是说,即使我们写出了下面的语句

int a;

我并没有“定义” a 的内容是 1 还是 2。但是它依然是一个兼具“声明”“定义”的语句。换句话说,执行完这个语句,a 就已经被分配了 4 个字节的存储空间,里面的内容虽然是不确定的,但是规则是一定的。比如如果这个值如果是全局变量,那么 a 中的值就是 0 。如果 a 是一个局部变量,那么 a 的值就是这个函数栈上之前这个虚拟空间位置的对应的值。

介绍完后再说一下 extern 。当出现这种语句的时候

extern int a;

我们只知道 a 是一个 int 变量,但是在这个语句里并没有为 a 分配存储空间和值。

这本是一个很朴素的认知,但是到了复杂类型的时候,就变得有些模糊,这可能是因为复杂类型的内存空间变大了,而且还有其他语言的干扰,比如 Java

Student cnx;

这个语句是没有为 cnx 分配一个 Student 的空间的,更谈不上 Student 中各个变量的值了。但是 CPP 是不同的,同样的语句,对于 CPP 而言,就真的给 cnx 这个变量分配了内存空间,而且连里面的内容都是按照固定的规则确定了的(基本上还是上面的规则)。这条语句依然是一个兼具声明和定义的语句。

2.4 变量的初始化

初始化说的就是我们在定义的时候那个“规则”被自定义的情况。初始化语句比如说:

int a(3);

这句话的意思是说,当 a 的空间被分配之后,这个空间里的值会被写成 3。对于简单类型,上面的语句与下面的语句等效,即这样也是初始化(我们最习惯这种):

int a = 3;

但是这样的语句就不是初始化(严谨地说,不是将 a 初始化为 3 的语句)

int a;
a = 3;

到了复杂类型的讨论的时候,事情就变得更加魔幻了,比如说这种结构

#include <iostream>

using namespace std;

class Node
{
private:
	int a;
public:
	Node(int);
	void Show() const;
};

Node::Node(int aa)
{
	a = aa;
}

void Node::Show() const
{
	cout << a << endl;
}

int main()
{
	Node node(3);
	
	node.Show();
	
	return 0;	
} 

里面这句话(或者下面这句话):

Node node(3);
Node node = node(3);

我也不知道叫不叫初始化,因为如果按照简单类型的理解,上面两种都叫做初始化,而且在某种程度上确实是。因为我们检验初始化与否有一个指标就是将变量用 const 修饰,被 const 变量只能是初始化,而不能赋值。所以这样的语句会有以下结果

const int a; // Wrong! uninitialized

const int a = 3;
a = 2; // Wrong! assigement of read-only variable

对于复杂类型,下面的语句都是可以通过的,说明这些语句都是初始化语句

const Node node1(3);   			// AC
const Node node2 = Node(2); 	// AC

但是我们在复杂结构里又有一个初始化列表的东西。这个东西说的就是初始化列表会在调用构造函数之前执行。那么也就是说,在初始化列表中执行的东西才是初始化。我上面的语句都是构造函数的语句,看上去就不应该是初始化了(其初始化的位置被初始化列表抢夺了),而它(即构造函数)偏偏是初始化语句。

我现在理解的是,初始化列表提供的类中所有数据成员的初始化,而构造函数提供了类对象的初始化。尽管对象的初始化看上去就是对象中所有数据成员的初始化。但是如果将这两个概念区分开来,在逻辑上就可以达到自洽。

2.5 参数和返回值的初始化

上面说的不太严谨,其实还有一个地方变量声明和初始化是分离的,那就是函数的参数和返回值的工作原理。我之前一直觉得,对于函数的参数,发生的是一个赋值的过程,即对于函数

int sum(int a, int b)
{
	return a+b;
}

语句实际发生的是(我的臆想)

// 语句
sum(1, 2);

// 实际
int a;
int b;
a = 1;
b = 2;
int ret; 
ret = a + b; 

但是实际是(真实)

int a(1); 
int b(2);
int ret(a + b);

所以无论是函数的参数还是返回值,都是一个初始化的操作,而不是一个先定义,后赋值的过程。只有这样才可以解释参数中和返回值中出现 const 关键词。

2.6 类型声明

除了函数声明和变量声明,还有一种声明是类型声明,比如这两种:

// class declaretion
class Node
{
private:
	int a;
public:
	Node(int);
	void Show() const;
};

// typedef declaretion
typedef int int_32;

// enum declaretion
enum bits{one = 1, two = 2, four = 4, eight = 8};

同样也是需要注意,这个仅仅是声明,并不会为任何东西开辟内存空间(除了类的静态成员)。


三、const

3.1 新类型

我觉得对于 const 最好的理解就是它不是一个修饰符,而是一个新的类型。说的明白一些,

int a;
const int b = 3;

直接将二者区分成不同的类型就好了。这是因为这个关键词发挥的作用很大(尤其是决定了某些性质),而且在各种声明和定义中都会出现(比如像 friend 关键词只用在声明中出现一次)。

我们可以将两者的相互赋值看做发生了一次类型转换。

3.2 指针常量和常量指针

其实就是记录一下,还是很简单的

const int *pa;		// 常量指针
int * const pa		// 指针常量

常量指针指的是这个指针指向的内容不可以通过这个被修改(注意还是可以用别的方式被修改)。指针常量指的是指针指向的内容可以被修改,但是指针本身的值是不可以被修改的。

3.3 常量与指针

其实说起来很复杂,但是我没有太多的时间了。只能说一下结论了。

对于一个指针常量,初始化的时候可以使用普通变量的地址或者常量的地址

const int a = 1;
int b = 2;
const int *pa = &a;		// VALID
const int *pb = &b;		// VALID

但是一个普通指针是没有办法接受常量的地址的

const int a = 1;
int *pa = &a;			// INVALID

对于指针的相互赋值,非常量指针是可以赋值给常量指针的,即下面的语句是可以通过编译的。

int a = 1;
int *pa = &a;
const int *pb = pa;

但是常量指针是无法赋值给常量指针的。即下面语句是无法通过的:

int a = 1;
const int *pa = &a;
int *pb = pa;

3.4 常量的显式限制

·const 真的是一个很强的限制,其中最容易疏忽的限制就发生在函数传递的时候,比如说下面的代码

#include <iostream>

using namespace std;

int sum(int *a, int *b)
{
	return *a + *b;
}

int main()
{
	const int a = 2;
	int b = 3;
	
	const int *pa = &a;
	
	cout << sum(pa, &b);
	
	return 0;	
} 

报错原因是 invalid conversion from ‘const int *’ to int *。也就是说,即使 sum 里没有更改内容,但是依然是不可以的。

3.5 引用语法糖

语法糖只指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

引用就是这样的一个语法糖。它其实可以被理解成一个常量指针,一个下面这样的东西

int a = 2;
int &b = a;
int *const c = &a;

所以当我们考虑引用的诸多限制的时候,我们其实都可以用常量指针的方式去考虑。比如它可以修改内容。这是因为它是一个指针。引用必须初始化,这是因为常量指针是一个常量,常量必须初始化。

如今再回看,发现其实也没有那么有道理,其实引用就是引用,就是和指针不同的语法现象,底层实现也并不一定是指针,而是一种“地址”更加合理。

3.6 本质总结

在我写完这些东西之后,有个东西豁然开朗,就是用类型转换的思想去理解 constconst 本质上提供了一种限制,我们只能做被限制的几种类型转换,如下所示:

int = const int				// VALID
const int = int				// VALID
    
int * = const int *			// INVALID
cosnt int * = int *			// VALID
    
&int = cosnt int *			// INVALID
&const int = int *			// VALID
    
int & = const int &			// INVALID
const int & = int &			// VALID

其实它就说明了一个事情,就是常量就是不可以修改的东西。为什么第一组的第二个可以,是因为虽然从类型转换的角度上是不行的,但是发生这种转换的情景就是一个常量去给一个变量赋值,而常量本身是没有变换的。但是后三组不是这样的,后三组本质上都是指针的赋值。如果指针指向的内容是一个常量,当这个指针被拷贝的时候,它必须保证它赋值的对象也不会修改这个常量。这就要求这个指针同样也得是一个常量指针。这就是后面三组第二个都不可以的原因。

不只是 int 上面的关系对于所有的类型,包括结构体和类都是成立的。


四、赋“值”

4.1 数组复制

数组赋值是我最想讨论的事情,我之前老觉得数组没有办法赋值,就是这样:

int a[3] = {1, 2, 3};
int b[3];
b = a;

是因为数组是一个很大的数据,所以没办法赋值。这个想法就是一个很错误的想法,可以说是我最大的错误想法。这个想法把语句是否正确的判断交个了一个叫做“很大” 的评判标准,显然是不合理的。

这种错误的想法让我在后面的各种学习中,经常会有很多错觉,比如

struct node
{
	int array[3];
};

struct node a;
struct node b;
a = b;

我总觉得这是错误的,首先是觉得 a 就不应该存在,node 里面可是一个数组啊,怎么会被这么轻松的声明出来。a = b 更是不能接受的事情,怎么能复制一个这么大的东西呢?

但是其实 CPP 的赋值很简单,其实就是将一片内存空间写上一定的值,只是要遵守一定的规则。但是写就是写了,不存在写多了就不能写的情况,要是想写,多大的数组都能给写了。也就是说,当发生 a = b 这个过程的时候,实际上就是把 a 里面的数组的内容都复制到 b 中,所以完成的是一次深拷贝。我们可以用下面的程序验证一下

#include <bits/stdc++.h>

using namespace std;

struct ArrayWrapper
{
	int array[3];
};

int main() 
{
	ArrayWrapper a;
	a.array[0] = 5;
	a.array[1] = 1;
	a.array[2] = 4;
	ArrayWrapper b = a;
	
	// 514
	cout << b.array[0] << b.array[1] << b.array[2] << endl;
	
	// change the b.array
	b.array[0] = 4;

	// 514, a doesn't changed
	cout << a.array[0] << a.array[1] << a.array[2] << endl;
	// 414, b.array is different from a.array
	cout << b.array[0] << b.array[1] << b.array[2] << endl;
	return 0;
}

那么到底是什么造成了上面的错觉,其实是数组的名字,比如下面这个

int a;
int b[10]

我们知道第一行的那个整型叫做 a,a 就是它的名字,我们让他等于 1 ,就是让 a 等于 1。但是底下这个整型数组的名字是啥,是 b 吗?不是,b 是指向这个数组首元素的常量指针。先不讨论常量的事情。让一个指针给另一个指针赋值,当然起不到复制数组的作用,因为 a 就不是个数组。换句话说,C 和 CPP 中所以的数组都是“匿名”的,它们都没有名字,自然没有办法被赋值,谁能操作一个没法操作的东西呢?而数组是“常量”指针的特性,甚至保证了一赋值,就报错的现象发生,这就是所谓的数组不能赋值。

4.2 永远值传递

在阐述完这个观点以后,我们才能引出一个更重要的东西,就是赋值到底是怎样发生的。其实想一想,这个其实是一个很多变的东西。比如说在 Java 中

Student cnx = qs;

这种东西其实并没有把 qs 的实体拷贝给 cnx,而是把一个类似于指针的东西赋给了 cnx,而 qs 数据的实体并没有被赋值给 cnx,只是 cnx 同样指向了 qs 指向的数据实体。这就引发了一个深拷贝的问题。然而 Java 在这个方面处理的很麻烦。因为对于所有的复杂结构(类),Java 并不提供实体的名称,而是只提供指针。

但是 CPP 并不是这样的,对于一个复杂的结构

struct node
{
	int array[3];
};

struct node a;
struct node b;
a = b;

说赋值,就是开辟一块新的内存空间,然后把这个空间里的内容改成右值。即是值传递的。那么所谓的指针传递,引用传递到底是什么呢?其实还是值传递。只不过这个值变成了指针的值,而不是指针指向内容的值。引用在前面阐述过了,只是一种特殊的指针而已。而指针不过是一种特殊的值罢了。


五、局部编译和链接

5.1 局部编译

C 和 C++ 的编译器都是局部编译器,也就是他们会把每个 .c 文件编译成一个目标文件或者库文件,最后再通过链接器链接成一个整体的二进制文件,而不是像 python 或者 java 一样,在执行是上一个整体

一种流行的解释是,C 和 C++ 都起源于远古时代,当时电脑内存小,“整体编译”的方式过于庞大,电脑承受不住,所以需要局部编译。是一种历史遗留问题。

我倒觉得是 C 和 C++ 的语言特性导致的,C 和 C++ 都是编译型语言,整体编译的策略会导致每次代码更新都重新编译整体,这无疑很耗费时间,局部编译策略降低了二进制文件间的耦合性,每次更新只需要更新一部分,重新链接即可,增强了便利性。同时 C 和 C++ 有大量的库,这些库并不保证开源,闭源库也无法整体编译。

此外,局部编译还可以缓解名称冲突问题,并不能说是一个完全的历史垃圾。

5.2 头文件

正因为编译是局部进行的,所以一个文件是没有整体项目的所有符号的,一个文件能看到的符号,就是这个文件里的符号,那显然只依靠这个文件里源码,是远远不够的,这个文件需要其他文件里的符号,所以我们发明了头文件,里面基本上就是符号的集合,这样就可以使得编译通过。

那有没有可能在头文件中不仅包括声明,还包括实现呢?当然可行的,头文件里可以有实现,但是这样的坏处就是没有办法享受局部编译的好处,因为局部编译的本质就是将不同的实现分开编译,写在头文件里就没有这个作用了。

另外,头文件还有一个意义是避免重复代码的书写,符号声明当然是一种,有一些宏定义或者内联函数虽然并不提供符号,但是作为工具类很好用,这个时候写到头文件中是正确的。

5.3 链接性

这个内容不止适用于 CPP,也适用于 C。我们物理上组织代码的方式一般是文件。我们说一个文件里放着相关的的代码。但是有一个问题就是我们常常需要将多个文件中的代码组织成一个程序。所以这些文件中的符号是存在冲突的可能的。所以我们要衡量各个符号对于各个文件的可见性,以及到底要怎样处理这种冲突。

我们描述的“文件的可见性”其实就是“链接性”。关于变量。我们一共有三种链接性,见下表

链接性 情况 解释
无链接性 代码块中 不会被除了这个代码块之外的地方看见
内部链接性 不在任何函数中,使用 static 可以被该文件看见,但是不会被其他文件看见
外部链接性 不在任何函数中 可以被所有的文件看见

我们所说的同名冲突情况,其实可以被细化为两种,一种是掩盖。这种情况发生在链接性不同的情况。比如说当代码块中有一个变量叫做 a,在全局有个变量叫做 a 。那么在这个全局的 a 就会被这个代码块中的 a 掩盖掉。

另一种就是真的冲突了。这种情况发生在链接性相同的情况,比如在一个文件中定义了全局变量 a,另一个文件也进行了相同的定义,那么就会导致编译器报错。为了避免这种情况(这种情况一般只出现在外部链接性变量上,因为其他的要是有冲突,那么一般是程序员自己的问题,他把一个变量自己定义了两遍)。我们引入了 extern 关键词,让变量可以做到多次声明,一次定义的效果。

对于 static 出现在代码块的情况,有如下有趣特点需要注意:

  • 存储特性为静态,即它在代码块不活跃的时候仍然存在,因此在两次函数调用之间,静态局部变量的值保持不变
  • 静态局部变量只在程序启动的时候初始化一次,以后再次调用函数的时候,不会进行初始化

以下面的代码举例

#include <iostream>

using namespace std;

void increase()
{
	static int ans = 3;
	ans++;
	cout << ans << endl;
}
 
int main()
{
	increase();
	increase();
	
	return 0;	
} 

它的输出是

4
5

对于函数而言,它们默认是全局链接性的,但是他们的多次声明并不需要使用 extern 关键词(当然也可以使用)。他们没有无链接性,因为他们不能被定义在代码块内。但是可以在函数前加入 static 让其变成内部链接性,即只本文件可见。

对于内联函数,他们并不需要遵守函数和变量遵守的“可以多次声明,但是只能有一次定义”的规则。他们可以被多次定义,只要保证每次定义的内容是相同就可以。