在 C++中,如果没有user-declared,class type会由编译器自动生成的函数有:default ctor、copy ctor、destructor和operator=(本文暂时不考虑move semantics)。当然,这些函数是在实际运行过程中调用到它们的时候再由编译器生成实际的代码再去执行,而非在编译这些class type class时直接生成在类的代码块内。
前三个函数其实很好理解,它们分别负责一个对象的产生和消亡。其中,default ctor的特点是:函数的括号内不接收任何参数,而copy ctor的特点则是:函数的括号内只有一个参数,是当前类已经存在的对象。当然,default ctor的自动生成条件比较苛刻,一定是在当前class里面没有任何一个user-declared constructor的情况下才去生成,《C++ Primer》为这里的这个自动生成的动作起了个名字,叫做合成。
无论是哪一种,任何一个constructor要做的事情都有很多。但有一点是要注意的,构造函数本身并不会去向系统申请一块内存空间,它只是单纯地在一块已经被分配好的内存空间上做一些工作(这些内存空间的来历并不是我写作本文所关注的重点)。这些工作里有一些是它必须去做的,也有一些是我们告诉它,它再去做的。
我们很容易想到,那些它必须去做的工作,势必和对象的产生息息相关,毕竟它的使命就是去创建一个对象。而后一部分工作被我们放在由我们自己设计的构造函数的函数体中,这一部分工作往往是构造函数已经完成了对象的创建,我们想让这个新生的对象去做的一些事情。
在那些它必须要完成的使命中,我们最关注的事情只有一件:data member的初始化,这也是本文写作的焦点。所谓初始化,就是在每一个对象获得内存的一刻,由我们规定,或是由系统自动,去存放在这块内存上的东西。有一个很好的思想:不管多么复杂的class,也可以拆分成一个个基本类型(内置类型)。因此在正式走进class前,我们有必要对这些基本类型的变量达成一些共识。
先回顾一下传统的面向过程的C语言,由于C语言中没有对象的概念,所以所有的东西都被称为“变量”。C语言的变量大体上分为:整型、浮点型、指针类型和聚合类型(数组和结构体)。一组值得我们留意的概念就是C语言中变量的声明和定义。关于声明的正式一点的说法是这样的:向程序表明变量的类型和名字,而定义是:创建一个与名字相关联的实体,说白了就是分配内存空间。在定义时,可以为变量指定初值。
在C中,我们几乎可以对变量的定义和声明不加以区分(不讨论函数的声明和定义):定义就是声明,声明就是定义。换言之,声明变量的同时我们也给它分配了内存空间。
例外的情况涉及到extern关键字。考虑一组文件的情况,我们在一个文件内写出这个句子extern double pi = 3.14;
,表示当前文件中我们声明并定义了变量pi,并为其初始化为3.14。当另一个文件内出现extern double pi;
的时候,就意味着我们可以使用别的文件中定义的pi了。
事实上,显式初始化extern修饰的变量会抵消extern的语义,也就是说extern double pi = 3.14;
里的extern完全可以省略。因此完全可以说extern就是一种特殊的声明,不具有定义的语义。我们可以认为extern声明实际代表着一种“权限”,同样具有“权限”意味的声明是类内友元的声明。
【【【填坑】】】
在每一名程序员学习C语言的开始,应该都会听老师讲过类似的话:“在第一次使用一个变量前,一定要将它初始化。”这并非意味着我们使用一个没有初始化的变量就一定会导致错误,它是一个优秀的编程习惯,可以帮助我们在很多复杂和庞大的工程中避免错误。
我们后面还会再讨论来自C语言的这些数据类型。事实上,C++为了将继承自C语言的这些数据类型与自身具备面向对象特征的类型所区分开,甚至专门产生了一个概念:POD(Plain Old Data),顾名思义,又普通(Plain)又老旧(Old)的一种数据。我们上面提及的C语言所有类型,都具有这种POD性质。后文我们还会详细探讨POD的一些细节。
这里我一定要插入《Effictive C++》的第一条:将C++视为一群语言的联邦。Scott Meyers认为,C++是四种语言所组成的联合语言,分别是:C、面向对象的C++、C++模板和STL。每一种小的语言内部,它们的语法具有高度的一致性。而一旦涉及到小语言间的交界,就要求我们做出一些规定,以便在高效编程的前提下选择最棒的处理策略。C++这门语言从出生开始,就和C语言有着数不清的纠葛,在数据类型这一部分尤为突出。形成这个思想,对理解下面的内容是很有帮助的。
画风转回C++,《C++ Primer 5th》是我们写作本文首要的参照标准,为了避免不必要的解释,我直接罗列本书在【2.2 变量】一节需要我们明确的内容:
- 不区分变量和对象,认为二者等价;
- 对象是一块内存(一块能够存储数据,并具有某种类型的内存);
- 所谓初始化,是指对象创建同时赋予其一个特殊值,是天生的;
- 初始化和赋值是两个截然不同的概念,赋值的意思是:擦除一个已经存在的对象的当前值,而以一个新值去替代(事实上,大多数operator=做的就是这样一件事情);
- 任何显式初始化的声明即成为定义,即有初值就是定义,没有初值有extern就是声明;
- 一个变量只能定义一次,但可以被多次声明(extern),这条规则是解决static成员问题的根本原则。
当然,一旦涉及到变量,或者说对象的讨论,一个躲不开的话题就是:这个对象是否具有static类型?这个对象是否具有const类型?在接下来的讨论中,我暂且默认我所讨论的data member是一群普通的小孩,既不具有static类型,又不具有const类型。
在涉及到更加深层的内容前,我决定首先解决一个问题,这个问题也是我写作本文的直接原因:Direct initialization & Copy initialization 到底有什么区别?
看一下《C++ Primer 5th》的说法:
- 直接初始化:不使用等号初始化一个变量
- 拷贝初始化:使用等号初始化一个变量,编译器把等号右侧的值拷贝到左侧新创建的对象中去
而《C++ Primer 4th》中:
对于class type对象,直接初始化和拷贝初始化的代表两种不同的实现方式:
- 直接初始化:直接调用与实参匹配的构造函数,直接初始化也可以调用拷贝构造函数;
- 拷贝初始化:直接调用拷贝构造函数,或先使用合适的构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象”,也就是说,拷贝初始化总是调用拷贝构造函数
来看一些例子:
string s1("hiya"); //直接初始化,调用string中参数为const string的构造函数
string s2 = "hiya"; //拷贝初始化,先在左侧调用合适的构造函数生成初始值为"hiya"的临时对象
//再调用copy ctor将临时对象的值拷贝给s2
string s3(s1); //直接初始化,调用string的copy ctor
string s4 = s2; //拷贝初始化,调用string的copy ctor
string s5 = string(); //拷贝初始化,先调用default ctor生成右侧的临时对象,再调用copy ctor
【注意】直接初始化和拷贝初始化的根本区别就是有没有等号,至于怎么调用、调用哪个构造函数是实现方式上的不同。
在这里我们可以回答开篇留下的一个小问题,为什么编译器在自动生成default ctor、copy ctor和destructor这些主宰对象生杀大权的函数的时候,还会生成一个看似与它们没什么关系的operator=,因为拷贝初始化是C++规定的两种初始化形式之一,而这种初始化形式需要我们去重载赋值运算符。
更进一步,假如我们在string类里,将它的copy ctor声明为private甚至直接=delete掉,以上五条语句将只有第一条可以运行。对吗?按照理论上C++标准的规定,是对的。而事实上,不同的编译器对于上面这件事的处理结果是截然不同的,这绝非上古时期编译器的差异,直到今天,2021年,这个差别仍然存在。
我采用GCC 9.2去测试,实际结果与理论结果相同:s1能通过编译,而s2、s3、s4、s5均无法通过编译。而当我使用MSVC-160去测试,当我把copy ctor声明为private甚至直接=delete掉,结果是:s1、s2、s5能通过编译,而s3、s4无法通过编译。
两种不同编译器的差别在于s2和s5。s3和s4都直接调用copy ctor,而当它们不具备使用copy ctor的权限,或copy ctor干脆不可用的情况下,它们无法通过编译是理所应当的。问题是,明明应该调用copy ctor的s2和s5,为什么在MSVC环境下通过了编译?
因为MSVC对于标准的实现有了一些改动。在拷贝初始化的过程中,它直接将等号右侧的内容作为初始值,然后直接调用相应的构造函数,无论右侧是一个常量(“hiya”)还是一个同类型的对象(s4)。也就是说,当s2初始化的时候,编译器根本就不经过copy ctor,那么copy ctor是否可访问甚至是否存在,对于s2来说都没有什么意义了。因此,拷贝初始化和直接初始化在MSVC中只有形式上的差异,而没有本质上的差别。
值得一提的是,尽管s5的右侧是一个临时对象,但是并没有调用copy ctor,而是直接为s5调用了default ctor,这是为什么?因为copy ctor的参数是一个同类对象的指针,很关键的一个地方是一切临时对象都是右值,右值无法被寻址。
接下来我们要去填一个开头留下的坑,关于C++的类型。其实我在刚开始阅读C++的英文文档的时候特别郁闷,因为我觉得什么东西在他们那里都叫type,static叫做type、const也叫做type。在《C++ Primer 5th》的第三章,也涉及到了一些奇怪的type,有一些type用来描述一些“距离”的:
- ptrdiff_t:两个pointer之差,类型是ptrdiff_t;
- difference_type:两个iterator之差,类型是difference_type,要注意iterator的类里面只重载了减运算符,并没有重载加运算符,因此只能相减不能相加;
在C里面,我们直接对指向数组元素的指针+1或者-1去操纵它们在数组的元素间移动,事实上,C++也是这样,ptrdiff_t和difference_type本质都是有符号类型(距离可正可负)的数字,只不过出于一些类的设计上的需要,我们有必要为这个unsigned int声明许许多多不同的名字。
还有一些奇怪的type比如:
- size_t:常常用来计数,①表示数组下标的类型;②sizeof运算符结果的类型;
- size_type:①表示vector下标的类型;②string和vector内部定义的类型,用来表示容器的长度。
它们都是无符号类型。
在这里其实可以提到用这些奇怪的名字去代替以前我们常用的int、unsigned的众多好处之一:提高了可移植性。我们在自己的程序中使用这些名字,而这些名字所代表的含义其实是系统自身为我们定义好的,比如32位系统中,size_t被定义为unsigned int,而64位系统中,它被定义为unsigned long。一旦没有这些名字,我们的程序中使用最朴素的unsigned int或者unsigned long,那么在不同系统间移植的时候,就免不了手动去修改这些类型的麻烦。可以这样理解,这些统一的名字,为我们的程序在不同的系统上提供了一套通用的接口。
在C++中,为基本类型取名字的操作,叫做类型别名。【【【坑】】】
基本类型转换【【【坑】】】
成员类型【【【坑】】】
说了这么多,下面终于到了一个激动人心的部分,这里没有前面那么多麻烦的语法,而是涉及到C++的两门“子语言”——C和面向对象C++的“边界”,也是我们前面提到过的:POD性质。不过在正式引入POD的概念之前,我们还是先来看一个同样很重要的概念:scalar type,标量。
引用cppreference的说法,C++的标量类型包括两部分:算术类型和指针类型。所谓算术类型也包含两部分:整型和浮点型。而包含了指针类型,就意味着不但普通的指针是标量,成员指针甚至std::nullptr_t【【【坑】】】也是标量。其实还有一个东西也是标量:枚举类型。
为了严格地区分C和面向对象C++的边界,有必要提及C语言中关于标量的说法:scalar type in C是与 compound type in C(复合类型)成对提出的。在C中,scalar type和compound type的区分方式特别简单:标量类型只能有一个值,而复合类型可以包含多个值。所有C的复合类型就包括数组、结构体和共用体咯,并且按照C语言的标准,枚举体属于标量类型。
再次切换回C++,《C++ Primer》对C++中的复合类型有着严格的定义:复合类型是指基于其它类型定义的类型,引用和指针均属于复合类型。在C++中,指针竟然既是标量类型,又是复合类型!换言之,在C中成对存在的两个概念,在C++中几乎可以说是风马牛不相及。我不由得再次回忆起Scott Meyers的伟大思想,严格地区分每一门子语言间的边界,实在是揣摩C++设计思想的得力手段。
更深一层我们可以说:C++的标量类型是一种具有加法运算符内置功能的类型,并且没有重载,包括算术类型、指针、成员指针、枚举和std::nullptr_t。
C++11在标量这件事情上引入了一个全新的类模板std::is_scalar<class T>
,它的模板参数是一个类型T。而它的作用是回答一个问题:T是不是一个标量类型?当我们输出std::is_scalar<int>::value
这个东西,就会得到一个true,而当我们把模板参数换成一个自定义类型,那就得到一个false。
既然提到了std::is_scalar
,我就不妨顺便说一点和本文的写作中心关系没有那么紧密的内容。但是下面这些东西是我认为C++标准库设计的一种精妙的思想,多了解一些总归是好的。OK,让我们把语言模式从“面向的对象的C++”切换到“STL”。
侯捷老师提到过,C++98的STL几乎全是使用模板完成的,而C++11在STL设计理念上出现了重大的变动:引入了大量的OOP设计方式,整个标准库里充满了一层又一层的继承。相较于依照11标准设计的GNU C4.9,他更喜欢依照98标准设计的GNU C2.9,理由是几乎同样的功能,老版本的实现更加简单清晰,也没有“为了继承而继承”,去设计一些逻辑上根本说不通的继承关系。不过其实在我看来,两种设计理念相较而言,11标准也没有那么差。尤其是它做的一些模块化的设计,通过对一些具有特殊意义的base class的继承,能够让一段代码实现很多功能,std::is_scalar
就是一例。
std::integral_constant
是std::is_scalar
的基类,这个类是C++11标准库中一个很特殊也很重要的类型。顾名思义,它是一个能够为我们提供“整型常量”的类型,从它的实现上我们可以更进一步说,它能够为我们提供一个“编译时”的整型常量。
插入一个我个人的见解,我认为C++版本迭代的一条主线就是:把更多的事放到编译期去做,尽可能去减少运行时开销。这条主线上一个重要的标志就是C++11中constexpr关键字的出现。全世界对C++新版本应用最全面的软件,一定是那个版本的STL。每一次版本迭代,每一次引入新特性,那么相应的标准库的设计和实现,也一定会大量应用这些新特性。我们来看和constexpr关键字同时出现的,来自C++11标准库的std::integral_constant
的源码:
template <class T, T v> //T是一个整型常量的类型,v是T类型的一个值
struct integral_constant {
static constexpr T value = v; //v的值可以通过integral_constant::访问
typedef T value_type; //为T定义一个alias别名叫做value_type
typedef integral_constant<T,v> type; //为通过<T,v>实例化的这个对象自己起别名
constexpr operator T() { return v; } //成员函数,返回value的值
};
这里有一个很重要的东西。以前我们知道类的成员包括数据成员和函数成员,而这里我们认识了类的第三种成员,叫做类型成员,由typedef声明。《C++ Primer》对此有以下解释:
- 类型成员通常出现在类的开头,以确保所有使用该类型的成员都出现在类型名的定义后——类型成员也是类型,所有类型都要先声明再使用;
- 一般来说,内层作用域可以重新定义在外层作用域出现过的名字,哪怕这个名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字A,而A是类型名,则不能在之后用typedef重新定义A。
这个类模板里面的两个类型成员分别是value_type和type,value_type就是模板参数T,即常量的类型,而type的类型是模板实例化所生成的类。这个类用一个静态数据成员value去存放模板参数v,并设计了一个constexpr成员函数返回v的值。一个模板中只有一个静态数据成员,这种反常的现象应该立马引起我们的警觉:设计这个模板,很可能并不是为了将它实例化并创建一个对象。换句话说,我们需要的仅仅是由它所产生的类。
这里v是一个非类型模板参数,关于这个话题《C++ Primer》有如下解释:
- 非类型参数可以是一个整型、一个指向object或function的指针或一个左值引用;
- 绑定整型参数的实参必须是一个常量表达式;
- 绑定到指针或引用的实参必须具有静态生存期。
这是很好理解的事情,模板需要经过两次编译才能变成可以执行的代码。第一次编译发生在模板定义,对模板代码做语法检测,第二次编译发生在模板实例化,对模板的参数类型进行检测,并生成一个实际的类的代码。不管哪次都是在编译的时候去做的,假如你的模板实参不能在编译的时候被处理,那真是大无语事件。
话说回来,看完了这个integral_constant的代码,怎么还是觉得它没什么用呢?只对着一根木棒看,那无论如何也觉察不到它能做什么。直到看见由他做成的筷子或者鼓槌,才能体会到它的作用。integral_constant就是这样一根木棒,它有两根重要的鼓槌:std::ture_type
和std::false_type
。
std::ture_type
并非是一个派生类,而仅仅是integral_constant某个实例化类型的一种别名,其声明为:
typedef integral_constant<bool,true> true_type;
这里T是bool,v是true,那我去调用true_type::operator bool()就会返回一个true,std::false_type
也是一样的道理,就不赘述了。顺便一提,实例化是指调用模板的时候显式地为模板参数赋值,进入泛化模板,而特化是设计模板的时候,为一些模板参数指定具体的值,并额外设计一套代码。只指定了一部分模板参数取值特化称为偏特化,偏特化的代码是一套模板,还需要实例化才能使用。指定了所有模板参数取值的特化称为全特化,全特化的代码就是一个具体的类,这个类不需要二次编译。调用模板参数时,一旦检测到实参与某个全特化所指定的实参相符,则不需二次编译,直接执行相应的类代码。
鼓槌有了,鼓在哪里呢?是时候回到我们的初衷了。看std::is_scalar
的源码:
template<class T>
struct is_scalar :
std::integral_constant < bool, is_arithmetic<T>::value || is_pointer<T>::value || is_member_pointer<T>::value || is_enum<T>::value || is_same<nullptr_t, typename remove_cv<T>::type>::value>
{};
这是一个有趣的继承,is_scalar仅仅对integral_constant做了继承,而自己的代码块是空的什么额外的事情也没做。所以我觉得我们完全可以把它看成和true_type or false_type一样的一个别名,只不过这里采用了继承的方式。等下我会说我对这两种看起来没什么区别的手法的认识,但是现在还是回到源码本身。
在这个继承里面又用到了一种C++11新特性:模板模板参数【【【坑】】】不同与我们平常接触到的大多数模板模板参数,如:template< Class T, Allocator std::allocator<T> >
这种,第一个模板参数是第二个模板参数即模板模板参数的参数的情况,这里是派生类的模板参数作为基类模板模板参数的参数的情况。
我们来看integral_constant的模板实参,只要关注第二个。由于第一参数是bool,不难推测这一长串东西得到的结果应该是true或false(第二模板参数v是第一模板参数T类型的常量表达式)。也就是说这么多“或”里的每一个表达式的值都应该是true或false。仔细观察这些表达式,我们看到了很多熟悉的名字:arithmetic、pointer、member pointer、enum、nullptr_t… …这不就是一开始所说的,标量类型的组成部分吗?
这时请重新从设计这个模板的初衷出发。is_scalar<T>::value
是为了回答一个问题:模板参数T是否是一个标量类型?通过追踪源码,我们知道is_scalar的value就是后面那一长串“或”的值。我们不妨将is_scalar的设计初衷套用在is_arithmetic这些东西上,是不是我们可以说,is_arithmetic的初衷,也是为了回答一个问题:它的模板参数T是不是一个算术类型?
是的,就是这样。当我们向is_scalar提问的时候,它又分别去提问is_arithmetic、is_pointer这些东西:T是算术类型吗?T是指针吗?T是成员指针吗?T是枚举体吗?T是nullptr吗?只要is_scalar在这些提问里收到了一个true的答案,由于“或”的关系,它的value也将是true,那么它用来回答我们所提出的问题的答案,就是true!
亦即,如果T是一个标量类型,那么is_scalar就继承了integral_constant < bool, true>,这个东西正是我们之前所定义的true_type!(只取决于T是否为标量类型,与const/volatite属性无关)也就是说,所有的这些这些is_xxx都是从integral_constant继承为true_type或false_type,这也取决于对于我们提出的问题,is_xxx给出的答案是ture还是false。
鼓槌终于擂响了战鼓!多么荡气回肠的设计!
这些is_xxx,就是C++标准库的重要组成部分——模板元基础库type_traits,type_traits里有很多这样的模板和别名,它们存在的价值,就是回答我们对于某一个类型、某一个函数甚至是某一个具体的变量,所提出的一个又一个问题。
差点忘了,最后我来说一下我对typedef和继承这两种手法的认识。如果硬说它们有区别,我觉得大概就是继承产生了这么多的is_xxx,都是用来回答某个具体的问题,而typedef定义的true_type和false_type,则只是一种抽象的是和否,如果没有具体的问题,它们也没什么意义。当然,它们的本质都是一样的,都是对integral_constant的实例化。
关于标量类型,就说到这里,接下来我们回到POD。