《C++标准程序库》学习笔记(一)C++相关特性
抱着本厚厚的《C++标准库》读了几天,想想也该写点关于用法的总结,一来怕今后容易忘记,二来将书上的事例重新敲一遍,巩固对程序库相关知识的了解。今天开第一篇,以后不固定更新。当然,笔者所读为该书为基于C++98的第一版,已有一定的年代感,不过虽然C++11的推出已有一定的时日,但是在普及上还需要一定的时间,因而,这本中文译本还是有一定的可读性的。这本书更新的版本为英文第二版,很遗憾还未出现其中文译本。
由于是开篇,本文所讲都很基础,但这些基础内容对后面的学习是非常重要的。
1 C++标准
C++的标准化是一个漫长的过程。为何要标准化?作为一个语言,如果不设立相应的标准规格那是万万不能的。世界上有很多C++程序员,他们都有各自的编程风格,如果不做统一的话,那么这门语言将会演变出相当多个版本,这也会让C++进入分崩离析的状态,其中的一个严重后果就是兼容性问题,因而,标准化是必不可少的,标准规格的建立,是C++的一个重要里程碑。
标准程序库是C++标准规格的一部分,提供一系列核心组件,用以支持I/O、字符串(string)、容器(数据结构)、算法(排序、搜索、合并等等)、数值计算、国别(例如不同字符集,character set)等主题。
2 C++语言特性
C++语言核心和C++程序库是同时被标准化的。C++每5年会有一个新版本。如果你没有紧跟其发展,可能会对某些新的语言特性大感惊讶。接下来的几个小节,将描述与C++标准程序库有关的几个最重要的语言特性。
2.1 template(模板)
程序库中几乎所有东西都被设计为template形式。不支持template,就不能使用标准程序库。
所谓template,是针对“一个或多个尚未明确的类型”所撰写的函数或类别。使用templete时,可以显式地或隐式地将类型当做参数来传递。下面的一个典型例子,传回两数中的较大数:
template <class T> const T& max( const T& a, const T& b) { return a < b ? b : a; }
在这里,第一行将T定义为任意数据类型,在函数被调用时由调用者指定。任何合法标示符都可以拿来做参数名,但通常以T表示,这差不多成了一个“准”标准。这个类别用关键字class引导,但类型本身不一定得是class-------任何数据类型只要提供template定义式内所用到的操作,都可适用于此template。
遵循同样原则,你可以将class参数化,并以任意类型作为实际参数。这一点对容器类别非常有用。你可以实现出“有能力操控任意类型元素”的容器。C++标准程序库提供了许多模板容器类。
欲实现C++标准程序库的完整功能,编译器不仅要提供一般的template支持,还需要很多新的template标准特性,以下分别探讨。
2.1.1 Nontype Templates参数(非类型模板参数)
类型(type)可以作为模板(template)参数,非类型(nontype)也可以作为模板(template)参数。因而后者可被看为前者的一部分。例如,可以把标准类型bitset<>的bits数量以template参数指定。以下定义了两个由bits构成的容器,分别为32个bits空间和50个bits空间:
bitset<32> flags32; //32个bits空间的bitset bitset<50> flags50; //50个bits空间的bitset
这些bitsets由于使用不同的template参数,所以有不同的类型,不能互相复制(assign)或比较(除非提供了相应的类型转换机制)。
2.1.2 Default Template Parameters(缺省模板参数)
template classes可以缺省参数。例如一下声明,允许你使用一个或两个template参数来声明MyClass对象:
template <class T, class container = vector<T> > //注意两个>之间必须有一个空格,如果没写空格“>>”会被解读为移位运算子,导致语法错误。 class MyClass;
如果只传给它一个参数,那么缺省参数可作为第二个参数使用:
//以下两条语句等价 MyClass<int> x1; MyClass<int, vector<int> > x1;
注意:template缺省参数可根据前一个(或前一些)参数定义。
2.1.3 关键字typename
关键字typename被用来作为类型之前的标识符号。考虑下面例子:
template <class T> class MyClass { typename T::SubType* ptr; //To do something... };
这里,typename指出SubType是class T中定义的一个类型,因此ptr是一个指向T::SubType类型的指针。如果没有关键字typename,Subtype会被当成一个static成员,于是:
T::Subtype * ptr 会被解释为类型T内的数值Subtype与ptr的乘积。
Subtype成为一个类型的条件是,任何一个用来取代T的类型,其内都必须提供一个内部类型(inner type)Subtype的定义。例如,将类型Q当做template参数:
MyClass<Q> x;
必要条件是类型Q有如下的内部类型定义:
class Q { typedef int SubType; //To do something... };
注意,如果要把一个template中的某个标识符号指定为一种型别,就算意图显而易见,关键字typename也不可或缺,因此C++的一般规则是,除了以typename修饰之外,template内的任何标识符号都被视为一个值(value)而非一个类型。
typename还可以在template声明中用来替换关键字class:
template <typename T> class MyClass;
2.1.4 Member Template(成员模板)
类成员函数可以是个template(模板),但是这样的成员模板既不能是virtual,也不能缺省参数。例如:
class MyClass { //To do something... template<class T> void f(T); //To do something... };
在这里,MyClass::f() 声明了一个成员函数集,适用任何类型参数。只要某个类型提供有f()用到的所有操作,它就可以被当做参数传递进去。这个特性通常用来为模板类中的成员提供自动类型转换。例如以下定义式中,assign()的参数x,其类型必须和调用端所提供的对象的类型完全吻合。
template <class T> class MyClass { private: T value; public: void assign(const MyClass<T>& x) { //x必须和*this有同样的T value = x.value; } //To do something... };
即使两个类型之间可以自动转换,如果我们对assign()使用不同的template类型,也会出错:
void f() { MyClass<double> d; MyClass<int> i; d.assign(d); //OK a.assign(i); //错误,i是MyClass<int>,但这里需要MyClass<double> }
如果C++允许我们为成员函数提供不同的template类型,就可以放宽“必须精确吻合”这条规则:只要类型可被赋值,就可以被当做上述成员模板函数的参数。
template <class T> class MyClass { private: T value; public: template <class X> //成员模板 void assign(const MyClass<X>& x) { //允许不同的模板类型 value.x.getValue(); } T getValue() const { return value; } //To do something... }; void f() { MyClass<double> d; MyClass<int> i; d.assign(d); //OK a.assign(i); //OK,int对于double是可赋值的。 }
请注意,现在,assign()的参数x和*this的类型并不相同,所以你不能够直接存取MyClass<>的private成员和protected成员,取而代之的是,此例中你必须使用类似getValue()之类的东西。
模板构造函数(templeta constructor)是成员模板的一种特殊形式,它通常用于“在复制对象时实现隐式类型转换”。注意,模板构造函数并不遮蔽隐式的复制构造函数。如果类型完全吻合,隐式复制构造函数就会被产生出来并被调用。举个例子:
template <class T> class MyClass { public: //带隐式类型转换的复制构造函数并不遮蔽隐式的复制构造函数 template <class U> MyClass(const &MyClass<U>& x); }; void f() { MyClass<double> xd; MyClass<double> xd2(xd); //调用内建复制构造函数(隐式) MyClass<int> xi(xd); //调用模板构造函数 //To do something... }
在这里,xd2和xd的类型完全一致,所以它被内建的复制构造函数初始化,xi和xd的类型不同,所以它使用模板构造韩式进行初始化。因此,撰写模板复制构造函数时,如果default辅助构造函数不符合你的要求,别忘了自己提供一个复制构造函数。
2.1.5 嵌套模板类
嵌套类本身也可以是个template:
template <class T> class MyClass { //To do something... template <class T2> //嵌套类 class NestedClass; //... };
2.2 基本类型的显式初始化
如果采用不含参数的、明确的构造函数调用语法,基本类型会被初始化为零:
int i1; //未定义的值 int i2 = int(); //初始化为0
这个特性可以确保我们在撰写template程序代码时,任何型别都有一个确切的初值。例如下面的这个函数中,x保证被初始化为零。
template <class T> void f() { T x = T(); //... }
2.3 异常处理
通过异常处理,C++标准程序库可以在不污染函数接口(亦即参数和返回值)的情况下处理异常。要注意的是这种概念叫做“异常处理”,而不是“错误处理”,两者未必相同。举个例子,许多时候用户的无效输入并非一种异常;这时候最好是在区域范围内采用一般的错误处理技术来处理。
C++标准库提供了一些通用的异常处理特性,例如标准异常类别(standard exception classes)和auto_ptr类别。
2.4 Namespaces(命名空间)
越来越多的软件由程序库、模块和组件拼凑而成。各种不同事物的结合,可能导致一场名称大冲突。Namespaces正是用来解决此问题的。
namespaces将不同的标识符号集合在一个具名作用域(names scope)内。如果你在namespace之内定义所有标识符号,则namespace本身就成了唯一可能与其他全局符号冲突的标识符号。你必须在标识符号前加上namespace名字,才能援引namespace内的符号,这和class处理方式雷同。namespace的名字和标识符号之间以::分割开来(这个符号及其意义和class与其成员之间的联系有点类似)。
//在命名空间josuttis中定义标识符号 namespace josuttis { class File; void myGlobalFunc(); //... } //使用命名空间中的标识符 josuttis::File obj; josuttis::myGlobalFunc();
不同于class的是,namespace是开放的,你可以在不同模块中定义和扩展namespace。因此你可以使用namespace来定义模块、程序库或组件,甚至在多个文件之间完成。namespace定义的是逻辑模块,而非实质模块。请注意,在UML及其他建模表示法中,模块又被成为package。
如果某个函数的一个或多个参数类型,定义于函数所处的namespace中,那么你可以不必为该函数指定namespace。这个规则成为Koenig lookup。例如:
//在命名空间josuttis中定义标识符号 namespace josuttis { class File; void myGlobalFunc(File& ); //... } //使用命名空间中的标识符 josuttis::File obj; myGlobalFunc(obj);
通过using声明(using declaration),我们可以避免一再写出冗长的namespace名称,例如一下声明:
using josuttis::File; //using declaration
会使File成为当前作用域内代表josuttis::File的一个同义字。
Using directive会使namespace内的所有名字曝光,它等于将这些名字声明于namespace之外。但这么一来,名称冲突问题可能死灰复燃。例如:
using namespace josuttis; //using directive
会使File和myGlobalFunc()在当前作用域内完全曝光。如果全局范围内已存在同名的File和myGlobalFunc(),而且使用者不加任何资格饰词地使用这两个名字,编译器将东西难辨。
注意,如果场合(contex)不甚清楚(例如不清楚是在头文件、模块里还是在程序库里),你不应该使用using directive。这个指令可能会改变namespace的作用域,从而使程序代码被包含或使用于另一模块中,导致意外的行为发生。事实上在头文件中使用using directive相当不明智。
C++标准程序库在namespace std中定义了它的所有标识符号。
2.5 Bool类型
为了支持布尔值(真假),C++增加了bool类型。Bool可增加程序可读性,并允许你对bool值实现重载动作。两个常数true和false同时亦被引入C++。此外C++还提供bool值与整数值之间的自动转换。0值相当于false,非0值相当于true。
2.6 关键字explicit
通过关键字explicit的作用,我们可以禁止“单参数构造函数”被用于自动类型转换。典型的例子便是群集类型。你可以将初始长度作为参数传给构造函数,例如你可以声明一个构造函数,以stack的初始大小为参数:
class Stack { explicit Stack(int size); //用初始大小size构造Stack //... };
在这里,explicit的应用非常重要。如果没有explicit,这个构造函数有能力将一个Int自动转型为Stack。一旦发生这种情况,你甚至可以给Stack指派一个整数值而不会引起任何问题:
Stack s; s = 40; //糟糕,创建了一个有40个元素的Stack,并把它赋值给了s
自动类型转换动作会把40转换为有40个元素的Stack,并指派给s,这几乎肯定不是我们所要的结果。如果我们将构造函数声明为explicit,上述赋值操作就会导致编译错误(那很好)。
注意,explicit同样也能阻绝“以赋值语法进行带有转型操作的初始化”:
Stack s1(40); //OK Stack s2 = 40; //Error
这是因为一下两组操作:
X x; Y y(x); //显式转换
和
X x; Y y = x; //隐式转换
存在一个小差异。前者透过显式转换,根据类型X产生了一个类型Y的新对象;后者通过隐式转换,产生了一类型Y的新对象。
2.7 新的类型转换操作符
为了让你对“参数的显式类型转换”了解更加透彻,C++引入一下4个新的操作符:
2.7.1 static_cast
将一个值以符合逻辑的形式转换。这可看做是“利用原值重建一个新对象,并在设立初值时使用类型转换”。唯有当上述的类型转换有所定义,真个转换才会成功。所谓“有所定义”,可以是语言内建规则,也可以是程序员自定的转换动作。例如:
float x; cout << static_cast<int>(x); //打印int型的x f(static_cast<string>("hello")); //调用f(),参数为string类型,而不是char*
2.7.2 dynamic_cast
将多态类型向下转型为其实际静态类型。这是唯一在执行期进行检验的转型动作。你可以用它来检验多个多态对象的类型,例如:
class Car; //抽象的基类,至少用于一个虚函数 class Cabriolet : public Car { //... }; class Limousine : public Car { //... }; void f(Car* cp) { Cabriolet* p = dynamic_cast<Cabriolet*>(cp); if(p == NULL) { //... } }
在这个例子中,面对实际静态类型为Cabriolet的对象,f()有特殊应对行为。当参数是个reference,而且类型转换失败时,dynamic_cast会丢出一个bad_cast异常。注意,从设计者角度而言,你应该在运用多态技术的程序中,避免这种“程序行为取决于具体类型”的写法。
2.7.3 const_cast
设定或去除类型的常数性,亦可去除volatile饰词。除此之外不允许任何转换。
2.7.4 reinterpret_cast
此操作符的行为由实际编译器定义。可能重新解释bits意义,但也不一定如此,使用此一转型动作通常带来不可移植性。
这些操作符取代了以往小圆括号所代表的旧式转型,能够清楚阐明转型的目的。小圆括号转型可替换dynamic_cast之外的其他三种转型,也因此当你运用它时,你无法明确显示使用它的确切理由。这些新式转型操作给了编译器更多信息,让编译器清楚知道转型的理由,并在转型失败时释出一份错误报告。
注意,这些操作符只接受一个参数。试看下面例子:
static_cast<Fraction>(15, 100) //糟糕,创建了Fraction(100)
在这个例子中你得不到你想要的结果。它只用一个数值100,将Fraction暂时对象设定初值,而非设定分子15、分母100。逗号在这里并不起分隔作用,而是形成一个comma操作符,将两个表达式组合为一个表达式,并传回第二个表达式作为最终结果。将15和100“转换”为分数的正确做法是:
Fraction(15, 100);
2.8 常数静态成员的初始化
如今,我们终于能够在class声明中对“整数型常数静态成员”直接赋予初值。初始化后,这个常数便可用于class之中,例如:
class MyClass { static const int num = 100; int elems[num]; };
注意,你还必须为class之中声明的常数静态成员,定义一个空间:
const int MyClass::num; //不用初始化
2.9 main()的定义式
在C++中有一个重要而又常被误解的问题,那就是正确而可移植的main()的唯一写法。根据C++标准,只有两种main()是可移植的:
int main() { //... }
和
int main(int argc, char* argv[]) { //... }
这里argv(命令行参数数组)也可以定义为char**。请注意,由于不允许“不言而喻”的返回类型int,所以返回类型必须明白写为int。你可以使用return语句来结束main(),但不必一定如此。这一点和C不同,换句话说,C++在main()的末尾定义了一个隐式地:
return 0;
这意味着如果你不采用return语句离开main(),实际上就表示成功退出(传回任何一个非0值都代表某种失败)。处于这个原因,本范例在main()尾端都没有return语句。有些编译器可能会对此发出警告,那正是标准制定前的黑暗日子。
3 总结
第一篇到此结束,关于复杂度的问题我在此省略,相信读者能理解。