一、 声明与定义

1. 基本概念和ODR

什么是声明?用于向程序表明变量的类型和名字。
什么是定义?变量的定义用于为变量分配存储空间。
二者有什么关系?首先,定义也是声明,因为在定义时我们向程序表明了变量的类型和名字,但声明不一定就是定义,不一定为变量分配空间,比如extern关键字声明的变量,还有类声明和函数声明,只是告诉编译器程序中有这个类型的叫这个名字的符号。同一变量的声明可以出现很多次,但定义只能出现一次。
但对于extern关键字修饰的变量,也可以直接进行定义,但是注意这个不能出现在头文件中,否则就会出现重定义错误,违反了ODR(One Definition Rule,单一定义原则)。

extern double pi = 3.1416;

所谓的单一定义原则,是整个项目中某个变量符号的定义只能出现一次,也就是只能为每个变量分配一次储存空间,试想一下,我们在头文件中定义一个变量,并在多个源文件中引用这个头文件,此时就会出现多重定义的错误。这也就是为什么头文件里只声明变量。但是并不是说一个变量的定义就只能出现一次,这其实和链接(linkage) 有关。

2. 链接

众所周知,C++ 程序运行时要经历预编译 - 编译 - 链接三个阶段,首先编译器递归处理每个源文件中的头文件,将其展开后替换原来的#include,同时替换所有的#define宏,然后将代码源文件编译成一个个的obj文件(每一个.cpp文件是一个编译单元,会生成一个.obj文件),最后链接各个obj文件中的符号得到可执行文件.exe

C++ 符号的链接主要用来确定一个符号的多个声明是否指向同一个实体,为此C++区分了三种链接:内部链接、外部链接和无链接

  1. 无链接
    最常见的链接方式,这种链接方式的变量实际不会被链接,即这个符号只指向自己(不会和其他任何同名的变量声明冲突),包括程序中的局部变量(由于链接实际上只链接不同文件中的“全局变量”,因此任何的局部变量都不会被链接)和块中声明的类型(类和枚举)定义。总结一下,块内(包括函数内)定义的符号都是无链接。

“块中声明的类型(类和枚举)定义”虽然听起来有点奇怪,但确实是可以的,因为C++允许在main函数中定义类,但是由于任何函数中都不能定义函数,因此main中定义的类,其成员函数只能在类内定义。

  1. 内部链接
    内部链接的变量符号可以在声明它的文件里随意访问,但是出了该文件就不行了。这包括静态全局变量(无论是否初始化)、静态函数、const全局变量、匿名命名空间中声明的函数,以及匿名命名空间中声明的类型(类和枚举)定义。内部链接的作用主要是让某些符号对其他文件不可见,以及避免命名冲突。

所谓匿名命名空间,也就是没有名字的命名空间(namespace关键字后无名字),因为没有名字,无法通过空间名和域限定符来获取其中声明的符号,也因此不可以在其他文件中访问,但在本文件中访问是不受限制的。匿名命名空间的出现,可以解决类型名无法标识为static的问题,为更多的符号提供了内部链接的方式。
另外,如果在头文件内声明/定义内部链接符号,实际上每个引用它的源文件里只持有一个副本,并且互不影响,指向的并不是同一块内存。

  1. 外部链接
    外部链接的变量符号同样可以在声明它的文件中随意访问,与内部链接不同的是,在其他文件中也可以访问,但是有一定的限制条件:必须进行前向声明!,也就是在使用该符号之前必须要在本文件中声明,然后编译器在链接时会去其他文件中寻找该符号的定义,如果不进行前向声明,编译器会报“找不到该符号”的错误。这包括所有函数(不包括前面提到的函数,因为函数其实默认extern)、非const和static的全局变量(无论是否初始化)、extern声明的全局const变量、以及inline声明的全局const变量,还有类及其成员函数和静态数据成员。

外部链接的符号由于其在所有文件中可见,因此一但这类符号的定义出现在超过一个的源文件中,就会因为违反ODR而造成多重定义错误,最常见的就是出现在头文件中,因为一般头文件都会被多个源文件引用,而预编译阶段,这些include引用都会被替换为头文件内容,从而头文件中出现的声明/定义会出现在每一个引用该头文件的源文件中。如果是一个外部链接符号的定义,就会出现多重定义错误,当然也有一些例外,比如:

  • inline的函数和变量,inline虽然是外部链接,但是允许多重定义,只不过前提是所有定义必须相同,然后编译器只会保留其中的一份定义来避免重定义错误,即所谓的“内联”,因此一般将inline用于头文件的变量/函数定义中,来保证各处定义的相同,同时又避免违反ODR原则。(注意,C++17才引入新的inline语义)
  • 类类型class。类类型虽然是外部链接,ODR 允许类类型在多个翻译单元中定义,特别地,对于需要该类类型完整定义的翻译单元中,必须有且仅有该类类型的一个定义,因此在头文件中定义类类型并在多个源文件中引用是合法的。但类的静态数据成员(其定义一定伴随着初始化,因为单纯的static只是声明)以及成员函数并没有被允许在多个翻译单元中定义,因此他们的类外定义不可以在多个源文件中出现(比如出现在头文件中然后被多个源文件引用),否则会报多重定义错误。
    而对于类内定义,静态数据成员的类内定义是不被允许的(即使在C++ 11就地初始化引入之后,你仍然不可以在类定义中定义和初始化一个静态数据成员,而只是用static声明,不支持的原因应该也是会引起多重定义错误),除非使用inline指定为显式内联(仅C++ 17及以后),或者使用constexpr来指定为隐式内联(C++ 11起就可以),都可以避免多重定义错误,或者指定静态成员为const,但请注意!这种方式实际上没有真正地定义一个变量,也就是没有为其分配内存,编译器只是把这个变量符号直接替换为了一个常量,只要你不对它取地址,一旦取地址,就会产生错误;而成员函数的类内定义是允许的,因为标准规定,类内的成员函数定义会被标记为隐式内联,因此不会违反ODR。
  • 模版。因为模版在实例化之前,实际并没有任何定义。

对于extern关键字声明的变量,不写初始化的话,是不会定义变量的,只是声明,因此可以写在头文件中,一旦写了初始化,就不能写在头文件中,否则引发多重定义错误。

二、初始化

1. 初始化的不同方式

所谓初始化,是在变量定义时,为变量指定初始值,比如int i = 0。C++ 从最早的版本到目前。共有6种基本的初始化方式,列举如下(实际有两种本质是一样的,因此只列举5种):

  1. 默认初始化(无初始化)
    这种方式不需要我们提供任何初始值,实际上也没有给变量进行任何初始化,因此也叫做无初始化,比如直接int i(一般定义的时候就会默认初始化),默认初始化的变量值可能是不确定的,比如局部变量,对于静态局部变量和全局变量,编译器会将他们放入程序的bss段(const全局变量会放入rdata段,作为只读数据,不允许修改),同时初始化为0,而对于局部非静态变量,则不会这样做,因此局部非静态变量的默认初始值是不确定的,在大部分时候,使用默认初始化的变量是不安全的。
  2. 拷贝初始化
    这种初始化的形式类似于int i = 0;,即类型 变量 = 初始值,相当于把初始值拷贝到变量所占的内存空间,当然,初始值可以是立即数,也可以是其他变量。对于类型变量,拷贝初始化可能会调用带参构造(如果类型包含单参数构造函数,并且初始值类型兼容该构造函数参数类型,且未标记为explicit),或者拷贝构造(如果初始值是一个同类型对象,但例外是,如果初始值是一个临时创建的同类型对象,那么编译器会将其优化为一次带参构造,而不是先构造临时对象再调用拷贝构造,这样做一是出于节省内存,二是防止类中的部分成员在临时对象销毁时释放造成的后续问题)。实际上对于类型变量的初始化,一般不采用这种方式,而是采用下面的直接初始化。
  3. 直接初始化
    形式为类型 变量(初始值...),这种方式主要使用在类型变量的初始化中,因为类型变量初始化如果采用拷贝初始化,会造成隐式问题,用直接初始化更加高效和安全。对内置简单类型变量进行直接初始化时,与上一种初始化方式无异,而对于用户定义类型变量进行直接初始化时,实际上是显式调用了对应类型的某一带参构造函数(前提是括号内参数必须匹配)。特别地,在类型变量定义时,如果其有无参构造函数,即使不带括号,也会默认调用该构造函数进行初始化,也就是Object obj等价于Object obj();,所以无参构造一般带explicit关键字以避免这种隐藏的初始化。
  4. 列表初始化(C++ 11引入)
    C++ 11引入的新的变量初始化方式,形式为类型 变量{初始值...}(直接列表初始化),或者类型 变量 = {初始值...}(拷贝列表初始化),用一对花括号包裹一个参数列表。这种初始化方式是被推荐的,因为它统一了原来的直接初始化和拷贝初始化的写法,不管是内置类型还是自定义类型变量,都可以方便地使用这种方式来初始化,并且它禁止窄化转换(即int width{4.5}这种是不允许的),同样的行为会被以前的初始化方式允许。
    需要注意的是,列表初始化有两种特殊情况:一是对于类类型,如果类不具有以初始值列表 (initializer list) 为参数的构造函数,那么列表初始化实际上是调用的该类的某个带参构造函数,否则列表初始化一定调用该类的以初始值列表 (initializer list) 为参数的构造函数。比如std::vector<int> vec{10}是初始化了一个长度为1,只包含一个元素10vector,而不是初始化了一个长度为10vector;二是对于聚合类型,列表初始化实际上是聚合初始化,按照内存布局来递归初始化,比如有下列例子:
class A {
public:
   int a, b;
};

class B {
public:
   A as[2];
};

constexpr B b{ 1, 2, 3, 4 };
// 等价于下面这种写法,但是不建议省略花括号,可能造成隐式问题
// 最好是严格按照内存成员顺序来初始化
constexpr B b{{{1, 2}, {3, 4}}}; 或 constexpr B b{A{1, 2}, A{3, 4}};
// 缺省值被自动补足为0
constexpr B b1{A{1}, A{3, 4}}; 等价于 constexpr B b1{A{1,, 0}, A{3, 4}};
  1. 值初始化(零初始化)
    形式为类型 变量{},类似上面的列表初始化,不过不指定任何初始值,此时变量会被初始化为0或者相应类型的空值,因此也叫零初始化,相比于默认初始化更加的安全。一般在我们不关心初始值的时候去使用这种初始化方式,比如std::map的初始化。

2. 类成员的初始化

对于非聚合类来说,需要调用类中的构造函数来完成类成员的初始化,我们常见的在构造函数体中给类成员赋值的语句其实并不是初始化,因为此时成员变量已经被初始化完毕,而是赋值语句,如果成员是类型变量,一定会调用对应类型的拷贝构造函数(编译器不会优化!)。
而如果要初始化成员变量,只有两种方式,一是在构造函数参数列表中初始化,二是就地初始化(C++ 11引入)。但是就地初始化实际只提供了一种缺省默认值,并不能完全取代构造函数,更多的是对构造函数的一种补充
而就初始化顺序而言,一个类的构造顺序是 按继承顺序依次调用虚基类的构造函数 -> 按继承顺序依次调用普通基类的构造函数 -> 按顺序依次调用类型成员的构造函数 -> 调用自己的构造函数,这其中,初始化列表初始化位于前两个阶段,也就是写在初始化列表中的成员变量会和基类子对象一起初始化,而可能的就地初始化发生在阶段3,在C++ 11之前,没有默认构造函数的类型成员初始化必须写在初始化列表中,在C++ 11以及以后,允许对这些成员就地初始化。对于既写在初始化列表又就地初始化的成员,只会进行一次初始化,并且初始化列表优先,故就地初始化会被忽略。最后才是在构造函数体内进行成员赋值。

对于上一篇 C++学习笔记2 —— 关于constexpr 中提到的字面值类型,如果是聚合类型,那么按照聚合初始化规则来初始化,允许缺省(因为编译器会自动补足0),否则就应当保证该类的每个非静态成员都被正确地初始化(不论是就地初始化还是初始化列表初始化),其基类子对象也应该初始化,从而可以在编译器完成求值,这要求所有涉及到的类型成员以及子对象都使用相应的constexpr来进行构造,静态成员不要求必须初始化。

三、补充

1. 储存类标识符

也许你注意到,staticextern是不可以同时使用的,这是因为它们都是储存类标识符(storage class identifier),这类标识符指定了变量的储存生命周期,以及链接类型。
到目前为止,该类标识符总共有6个,列举如下表:

标识符 意义 注解
extern 为变量指定静态(或线程本地)储存生命周期以及外部链接
static 为变量指定静态(或线程本地)储存生命周期以及内部链接
thread_local 为变量指定线程本地储存生命周期
mutable 用于类数据成员,允许其在const对象中被修改
auto 自动储存生命周期 C++11后过时,因为局部变量都是自动生命周期,现在的auto用于变量类型推断
register 自动储存生命周期,并且提示编译期将变量放在寄存器里 C++ 17后过时

注意这里的thread_local,其主要是为每个线程设置一个私有变量(或者说是线程域的私静态变量),只在线程初始化时初始化一次,因为正常来说,线程在栈上的变量是隔离的,但在堆上和静态储存区(bssdata段的变量)是共享的,加了thread_local的变量是线程内独有的,不会在线程之间共享,同时在线程内也只初始化一次,因此相当于线程域的静态变量。

2. const

C++中的const关键字有一点特殊,虽然被声明为const的变量不可变,但是确实可以通过修改其指针的指向来改变变量的值,但是这种改变是有局限的:

const int a = 9;
int* ptr = (int *)&a;
*ptr = 1;
std::cout << *ptr << " " << a << std::endl;

该代码段的输出是1 9,实际上a被放进了符号表,并在所有出现的地方被替换成常数9,而当取a的地址时,编译期才会为这个变量分配空间(这是一种优化),但是所有a出现的地方,仍然是常数9,即使你通过ptr修改了a的内存,所以const的语义其实类似于,访问被const修饰的符号,得到的值始终是一致的。

对于const对象,也就是const修饰的对象,比如const Object obj;,其只能访问类内的const函数/数据成员或者静态函数/数据成员(mutable数据成员例外,其可以被常对象和常对象函数访问),const函数也是同理。

对于成员函数,conststatic不能同时使用,因为const成员函数的语义是,不能访问类对象内的非const和非mutable成员,和static的语义冲突,对于mutable也是同理,这俩关键字是对象(实例)作用域内的,和static代表的类作用域冲突。