浅谈C++/C命名冲突
前言
在这里我会简要地介绍产生命名冲突的原因,和C++中处理命名冲突的方法,同时和C语言的解决办法进行比较。
相信你在阅读完之后一定会有收获。对于我们来说,了解编译器的编译链接过程才能更好的理解编译器是如何报错的,更能让我们把持细节。
其中有*的部分,是一些补充内容。
1. 编译链接
实际上,我们所写的代码都会经历一个翻译,链接的过程。
计算机是无法识别我们所写的代码的,计算机只能识别二进制的指令,所以我们所写的代码需要经过翻译才能被计算机识别。这个翻译就是编译所做的事情。由于我们写代码的时候大可能是由多个源文件组成的一个程序,需要将多个源文件联系起来就是链接的过程了,最后生成可执行程序。
(注意:多个源文件是分开进行的编译)
其中,编译分为如下阶段:
-
预编译
-
编译
-
汇编
然后进行多个文件的链接,大致流程如图:
大致一个项目的过程如下:
1.1 编译环节
下面会大致介绍一下编译过程会发生的事。
-
预编译。在预编译中,我们的编译器会干啥事呢?
a、头文件展开 #include
b、宏的展开 #define
c、消除注释
……
在Windows环境下经过预编译的文件会生成一个后缀为 .i 的文件;在Linux环境下经历预编译的环境下生成的后缀为.i的文件 -
编译。在编译的时候,编译器要干的事就很多了:
a、词法分析。这个步骤就是来识别“单词”的,例如:int a; 就会被识别为:“int”关键字,“a”标识符……b、语法分析。简单来说就是检查语法是否正确的。
c、语义分析。重点是检查语法结构是否正确,例如:int num = “hehe”,这样类型不匹配的语义错误。
d、符号表管理。符号表是一种数据结构,用于记录源文件中出现的全局的变量名、函数名等等,这个符号表后面还会用到。
在Windows环境下经过编译环境过后会生成一个后缀为.asm的文件;在Linux环境下经历编译过后会生成一个后缀为.s的文件。
-
汇编。在这个环节中,编译器会将代码翻译为计算机能识别的二进制指令。然后再进行下一步的处理,但是在这个时候,我们的符号表内还有一些外部符号需要处理,例如:一个外部函数,或者变量等等(等会处理的主题),还需要后面的链接过程进行进一步的处理。
在Windows环境下经过汇编环境过后会生成一个后缀为.obj的文件;在Linux环境下经历汇编过后生成一个后缀为.o的文件。
1.2 链接环节
链接过程涉及比较复杂,过多的不再介绍。我们主要介绍:链接环节会进行符号表的合并。那么整个项目中的全局变量和函数都会进入这个符号表,那么在刚才的汇编过程中的一些外部符号就得到处理了。
下面来举个例子来说明:
/* add.c文件 */
int Add(int x, int y)
{
return x + y;
}
// ...
/
/* test.c文件 */
int Add(int x, int y); //函数的声明
int main()
{
Add(1, 2);
return 0;
}
在上面的例子中,我们在add.c文件中定义了函数Add,在test.c文件中声明了函数Add。
(上面图片函数地址是假设的)
做个比喻:声明就好像是一个承诺,定义就是兑现承诺。编译过程的不会检查函数是否是实现的,它只会检查是否是承诺过的(声明),直到后面的链接过程后,就会将实现的函数的地址汇总进完整的符号表,函数承诺就相当于获得了兑现。1、那么如果我们使用了未定义的变量或者函数,那会发生什么呢?很显然,在我们刚刚讲过的1.1编译环节就谈到了会进行语法分析、语义分析,所以当我们使用了未定义的变量或者函数名时,就是编译环节就会报错。2、那么如果我们声明了变量或者函数,但是没有定义,那会发生什么呢?在刚刚的链接环节已经介绍了,声明的外部符号会在完整的符号表中去寻找,如果找不到有效地址那么就会出现链接错误。3、那么如果我们重定义了变量或者函数呢?那么在符号表中就会用同名的函数,但是它们的有效地址却不相同,对于编译器来说,就无法分辨调用哪一个变量或者函数了。这就是我们接下来要讨论的重点:命名冲突。
2. 处理方式
在介绍C++的处理方式之前,我们先来看C语言的处理方式
2.1 C语言static关键字
在C语言中,大概就有两种作用域:a、全局域 b、局部域。在全局域中的变量和对象是具有外部链接属性的(extern声明),对于它们来说,会有和其它源文件冲突的风险。比如说:在一个项目中,有人在a.c文件全局定义了Arr变量,另一个人在b.c文件全局定义了Arr变量(文件名和变量名皆是举例子,这种命名风格不可行),但是含义完全不同,这时候根据我们上面所说,编译器就会犯迷糊了,就会链接失败。又比如说:库里实现了一个函数,而程序员又自己写了一个同名的函数,同时包含了这个库函数的头文件,那么编译器应该听谁的呢?……所以在C语言中为了解决命名冲突的问题,我们需要使用关键字:static
2.1.1 static用法
我们首先回顾一下startic用法:
-
修饰局部变量时。该变量的存储类型从自动存储类型变为静态存储类型,即从栈区存储的变量变成了静态区存储的变量。
-
修饰全局变量时。本来处于全局域的变量是具有外部链接属性的,经过static修饰过后,该全局变量的链接属性变为内部链接属性:即作用域仅局限于本源文件,不可被其它源文件访问。
-
修饰函数。同样地,全局函数是具有外部链接属性的,经过static修饰过后的函数,其只能在定义的源文件中使用,对于其它文件来说就是不可访问的。
那么根据第2、3条修饰规则,我们可以发现,对于C语言来说解决命名冲突的方式就是对于仅仅在本源文件使用的全局变量或者函数,或者若干个函数需要共享同一组全局变量,可以将这些函数放在同一个源文件中,把他们所需要的变量也放在该源文件中并用static修饰,即可防止命名冲突。同时,注意:我们可以全局多定义同名函数,但是需要保证的是,没有static修饰的函数数量 <= 1,这样才能保证运行成功。
例如:
/* 源文件 */
static int g(int x)
{
//...
}
void func()
{
//...
g(); //本文件调用g();
}
* 2.1.2 extern关键字
说到static关键字,就不乏说到extern关键字了,这个关键字的作用是:声明一个外部对象(可以理解为:处于其它文件的全局变量)
例如:
/* add.c文件 */
int global_num = 10;//此处声明了一个全局变量global_num
int Add(int x, int y)
{
return x + y;
}
// ...
/
/* test.c文件 */
int Add(int x, int y); //函数的声明
//如果想在该文件使用该变量的话,做如下声明:
extern int global_num; //这是一个声明,不是定义
int main()
{
Add(1, 2);
return 0;
}
extern关键字显示说明了global_num的存储空间是在程序的其它地方分配的。
从编译器的角度来看,通过该声明,编译器知道会在链接的过程中找到这个变量的定义地方(地址)。
但是对于这个关键字还有很多细节:
例如,来看下面一个程序
// 说明:已在外源文件声明了arr1 和 arr2
/*
int arr1[3] = { 0 };
int arr2[3] = { 0 };
*/
extern int* arr1;
extern int arr2[]; //代码一
extern int arr2[3]; //代码二
int main()
{
printf("指针类型:%zd\n", sizeof(arr1));
printf("数组类型:%zd\n", sizeof(arr2));
return 0;
}
该程序运行的结果会让你大吃一惊!
(这是在VS2022x64坏境下进行的)
实际上,编译器对于代码一的处理是警告的
甚至是,将该[ ]的数字改变了也会影响结果。这样的使用会造成什么结果呢?这样的使用会造成许多意想不到的结果。比如说:在原定义中声明为了int型,但是在外部对象中声明为long,在不同环境下由于内存所占的字节不同。这样的话,由于两个这样对于其中一个的赋值,另一个可能得到不如意的结果。这样的结果是防不胜防的。
这样的结果不是我们所希望的。所以日常使用的过程之中,建议就是:少用全局变量!!!
2.2 C++命名空间
2.2.1 基本使用
我们的C++就看到了C语言的命名冲突的问题,灵感乍现之下创建了一个新的语法——命名空间。
首先我们先来了解一个常识:
在C++中有四种域({ }之中的)
- 全局域
- 局部域
- 命名空间域
- 类域
命名空间域就是今天的主题。其中全局域和局部域决定了一个变量的生命周期,而我们的类域和命名空间域不会。
根据我们上面的了解,我们知道了在同一作用域下,同名的变量会发生冲突,命名空间域就相当于设置了一个墙,防止与全局域中的变量发生冲突。那么我们可以在命名空间域中干什么呢?实际上,我们可以把它当作另类的全局域。
关键字:namespace。在这个命名空间域中我们可以定义任意类型,例如:内置类型,自定义类型,函数……
namespace Er
{
int b = 10;
typedef struct STNode
{
int val;
struct STNode* next;
}STNode, *PSTNode;
int ADD(int x, int y)
{
return x + y;
}
char c = '*';
}
访问方法也很简单:操作符 ::
例如:
namespace Er
{
int a = 10;
}
int a = 1;
int main()
{
int a = 2;
cout << a << endl; //局部域
cout << ::a << endl;//全局域
cout << Er::a << endl;//命名空间域
}
上面的示例给出了a的三种域的访问方式,我们可以看到全局域可以通过空命名空间的方式在局部域中访问。
实际上,对于C++标准库定义的命名空间就是std。我们需要使用里面的函数,类对象都是需要访问这个命名空间的(例如cout、cin),我们每次都去声明命名空间域就太过于麻烦了,所以我们采用展开命名空间的方式使用其中的变量。这里的展开命名空间又分为两种:1、部分展开 2、全部展开。
对于展开来说:关键词using
- 部分展开。例如:
// 例如: using std::cout
namespace Er
{
int a = 1;
int b = 2;
}
using Er::b; //部分展开的方法
- 全部展开
// 例如: using namespace std;
namespace Er
{
int a = 1;
int b = 2;
}
using namespace Er;
对于展开的变量或者命名空间来说,就相当于暴露在了全局域中,这样还是存在命名冲突的风险的,所以建议,在项目中对于标准库的命名空间,尽量展开几个常用的函数或者对象。
2.2.2 使用细节
- 同一项目下不同文件的同名命名空间是合并的。
namespace Er
{
int a = 10;
}
namespace Er
{
int b = 5;
}
int main()
{
cout << Er::a << endl; //在源文件2中可以访问到源文件1的Er命名空间变量a
}
上面的例子说明我们仍然有可能在我们不之情的情况下,在同一项目源文件同名命名空间定义了相同的变量,这个时候第二细节排上用场了。
同一源文件的命名空间合并通常是发生在编译阶段。同时需要注意:在使用分文件的命名空间的时候,通常需要注意使用头文件来声明命名空间。以确保各个源文件中对命名空间的使用是一致的。如果不使用头文件,还记得我们上面讲的吗?编译阶段每个源文件是各自编译各自的,所以,如果不采用头文件,我们就会发现编译器找不到合适的命名空间内的变量。而上面的例子中是在同一源文件下的操作。如果还是需要分源文件,不使用头文件,就可以使用extern声明。
- 命名空间可以嵌套。
举个例子:
namespace Cc
{
int a = 1;
namespace Kk
{
int b = 0;
int a = 2;
}
}
int main()
{
printf("Cc中的 a == %d\n", Cc3::a);
printf("Cc中的Kk中的 a == %d\n", Cc3::Kk::a);
printf("Cc中的Kk中的 b == %d\n", Cc3::Kk::b);
return 0;
}
上面的示例也给出了如何去合理的访问嵌套命名空间的方法。
对比C语言和C++解决命名空间的方法,我们看到,C++采用命名空间的方式是比C语言好上太多了,避免了C语言的诸多问题,极大的提高了程序员编写代码的灵活性。
至此对于命名冲突发生的原因、解决办法已经谈论的差不多了。如果有问题欢迎指出,作者接受大家的批评教育。
本文章有参考:《C陷阱与缺陷》