C++探秘学习笔记
书名:C++探秘:68讲贯通C++
英文名:Exploring C++ : The Programmer’s Introduction to C++
作者:[美] Ray Lischner(里斯纳)
译者:刘晓娜 林健 石小兵 李杰
出版社:人民邮电出版社
版本/版次:2011年1月第1版,2011年1月北京第1次印刷
第27讲 自定义类型
构造函数的初始化列表,形如下所示:
rational(int num, int den) : numerator(num), denominator(den) {…}
初始化列表时可选的,如果没有它,则数据成员是未初始化的。(?那么默认构造函数呢,如果一个类包含多个数据成员,使用这种语法是否太累?)
第28讲 重载操作符
对于C++预定义好的类型,我们无法对其重载操作符。换句话说,要出现操作符重载,操作数中必须至少包含一个自定义的类型。重载操作符的一个技巧是,使用相关的一个已重载好的操作符来实现重载。例如:
bool operator==(T a, T b) {…} bool operator!=( T a, T b) {return not (a == b);}
考虑到多一层函数调用的性能开销,可以适时使用inline关键字,例如:
bool operator==(T a, T b) {…} bool inline operator!=( T a, T b) {return not (a == b);}
对于琐碎的函数也可以使用inline关键字。inline提示编译器在函数调用点展开该函数。
第29讲 自定义IO操作符
对于输入操作符,即提取器(因为它从流中提取数据),其第一个参数为std::istream&。它必须是非常量引用,以便在函数中修改流对象。第二个参数因为要存储输入值,所以也必须是非常量引用。返回值通常就是第一个参数,类型为std::istream&,以便在同一表达式中连接多个输入操作符(就像:std::cin >> x >> y 之类的)。
函数体完成输入流的读取、解析和翻译工作。每个流用一个状态掩码跟踪错误,下表列出了可用的状态标志。
标志 | 描述 |
badbit | 不可恢复的错误 |
failbit | 非法输入或输出 |
eofbit | 文件尾 |
goodbit | 无错 |
第一个参数(std::istream&类型)的成员函数unget()使输入操作符回退多读入的字符,这关系到程序的后续执行。整数操作符与此类似:尽量多读入字符直到读到的字符不属于该整数为止,然后回退最后一个字符。e.g:输入操作符例子,其中rational为自定义的有理数类型,包含分子numerator(int型)、分母denominator(int型),有理数是形如4/3、1/2的数。
#include <ios> // declares failbit, etc. #include <istream> // declares std::istream and the necessary >> operators std::istream& operator>>(std::istream& in, rational& rat) { int n(0), d(0); char sep('\0'); if (not (in >> n >> sep)) // Error reading the numerator or the separator character. in.setstate(std::cin.failbit); else if (sep != '/') { // Read numerator successfully, but it is not followed by /. // Push sep back into the input stream, so the next input operation // will read it. in.unget(); rat.assign(n, 1); } else if (in >> d) // Successfully read numerator, separator, and denominator. rat.assign(n, d); else // Error reading denominator. in.setstate(std::cin.failbit); return in; }
对于输出操作符,即插入器(因为它把文字插入到输出流中),和输入操作符相似,输出操作符的第一个参数和返回类型均为std::ostream&,且返回值即为第一个参数,第二个参数可传递值或传入常量引用。输出操作符需要处理许多很复杂的输出格式,但一般我们自己重载它时不必如此,关键在于利用临时输入流将文字存在string类型字符串中。通常,可以使用std::ostringstream类型(该类型在<sstream>头文件中声明),使用方式与cout等输出流相同。操作完成后,使用成员函数str()返回生成的string类型字符串。将该字符串写入实际输出流中,并让输出流来处理宽度、对齐和填充等格式化问题。e.g:输出操作符例子,其中rational为自定义的有理数类型,包含分子numerator(int型)、分母denominator(int型),有理数是形如4/3、1/2的数。
#include <ostream> // declares the necessary << operators #include <sstream> // declares the std::ostringstream type std::ostream& operator<<(std::ostream& out, rational const& rat) { std::ostringstream tmp; tmp << rat.numerator; if (rat.denominator !=1) tmp << '/' << rat.denominator; out << tmp.str(); return out; } //不知道有木有toString()之类的函数可以使用?
一般来说,处理输入的代码通过检查流本身或输入操作符来判断输入是否出错,而输入操作符实际上返回流本身,因此这两种方法等价。流被当做bool类型时,它的值是其成员函数fail()结果的非。检查输入流的有效性会经过以下步骤。
1.调用bad()成员函数检测硬件错误。如果文件遇到了程序不可恢复的错误,则badbit被设置并且bad()返回true。
2.调用eof()成员函数来检测是否到达文件尾。如果文件到达文件尾,则eofbit被设置并且eof()函数返回true。
3.调用fail()成员函数来检测输入流状态字(即状态掩码)。如果发现badbit、eofbit、failbit位至少一个被设置,则fail()返回true。
注意,failbit位是用户代码内部逻辑来决定的,例如,如果用户代码要求输入整型,但客户端输入的是字符型,则该位会被设置。如果检测到输入流无效,程序可根据实际需求进行错误处理,或立即退出或继续运行。另外,可以通过调用流的clear()成员函数来清除错误状态。e.g:测试I/O操作符
// Tests for failbit only bool iofailure(std::istream& in) { return in.fail() and not in.bad(); } int main() { rational r(0); while (std::cin) { if (std::cin >> r) // Read succeeded, so no error state is set in the stream. std::cout << r << '\n'; else if (iofailure(std::cin)) { // Only failbit is set, meaning invalid input. Clear the error state, // and then skip the rest of the input line. std::cin.clear(); std::cin.ignore(std::numeric_limits<int>::max(), '\n'); } } if (std::cin.bad()) std::cerr << "Unrecoverable input error\n"; }
第30讲 赋值与初始化
如果要在C++中重载赋值操作符,那么它必须为成员函数。以非成员函数实现的操作符函数把每个操作数当做一个参数,即双目操作符函数是包含两个参数的函数,单目操作符函数是单参数函数。成员函数却不相同,因为所有的成员函数都有一个隐含的操作数(而且总是左操作数),即对象本身,所以这些函数减少了一个参数,即双目操作符函数是单参数函数,而单目操作符函数则不需要参数。
赋值操作符函数通常返回一个本对象类型的引用,值也为对象本身,即*this。如果没有编写赋值操作符函数,则编译器会自动生成一个,通常这就够用了。
编译器也会自动生成构造函数。特别是,复制构造函数会在必要时将一个对象实例的所有对象成员复制到另一个同类型的对象实例中。例如向函数按值传递实参时,编译器就调用复制构造函数将实参值复制到形参;或者在定义某个类型的变量x且用另一个同类型的变量(当然该变量已被初始化了)初始化x时,编译器也会通过复制构造函数生成该变量(即x)。
和赋值操作符函数一样,编译器的默认实现版本和我们需要的版本完全一样,因此也没必要手动编写复制构造函数了。如果没有为某个类型编写任何构造函数,则编译器同样会生成一个无参数的构造函数,即默认构造函数(default constructor,无参数的构造函数)。当定义一个没有初始化器的变量时,编译器则会调用该函数(即类型的默认构造函数)。注意:编译器生成的默认构造函数只是依次调用各数据成员的默认构造函数,如果某个数据成员是内置类型,则它不会被初始化。解决办法是:不让编译器生成默认构造函数,而是自定义一个。只要编写类的构造函数(不管是否带参),编译器就不会再生成默认构造函数了。但它仍会生成类的复制构造函数,除非自定义该复制构造函数。
赋值操作符
复制构造函数
默认构造函数
第31讲 编写类
构造函数是一个特殊的成员函数,它不能通过代码直接调用。在定义某类型的变量时,编译器会根据变量的初始化列表(如果有的话),并按照重载函数的通用规则,自动调用合适的构造函数。构造函数的编写与普通成员函数编写方法大体相同,但有几点区别。
- 无返回类型;
- 使用单一的return(无返回值得返回语句);
- 把类的名字作为函数名字;
- 在冒号后加入初始化列表来初始化数据成员。
尽管构造函数的初始化列表是可选的,但建议在构造函数参数列表圆括号后的冒号之后,总是写上初始化列表。初始化列表按照各数据成员在类的定义中出现的次序,依次初始化它们,而忽略在列表中的出现次序。为避免混淆,建议编写初始化列表的顺序与数据成员顺序相一致。
根据数据成员的类型是某个类还是内置内型,编译器会做出不同处理。每个成员的初始化列表可以有如下三种情况。
1.没有该成员。成员未出现在初始化列表中,或者根本没有初始化列表。则内置类型成员不初始化,类型为某个类的成员则由该类默认构造函数初始化。
struct demo { demo() {} //调用point()初始化pt_,但u_不初始化。 point pt_; int u_; };
2.没有初始化值。成员出现在初始化列表中,但圆括号内无值(成员名字后的圆括号不能省略)。成员为值初始化的(value-initialized):内置类型成员通过赋予转换为合适类型的零值;类型为某个类的成员,调用该类型的默认构造函数来完成值初始化(如果该类无默认构造函数,则编译器会报错;若该类无[任何]显式构造函数,则成员通过将这个类的每个成员进行值初始化,来完成这个成员本身的值初始化)。
3.这是一种正常情况,所有的成员均被正确的初始化。
注意:编译器为某个类型生成的默认构造函数并不会对该类型的数据成员进行任何的初始化动作,只有当对某个类型的数据成员(注意是数据成员而非单纯的变量)进行值初始化时,编译器才去值初始化这个数据成员的数据成员。测试代码如下:
#include <iostream> // point具有隐式默认构造函数,但它并不做任何数据成员的初始化工作。 struct point { double x_; double y_; }; // 因为在demo的构造函数中对数据成员pt_进行值初始化,此时编译器才自动的值初始化pt_的数据成员。 struct demo { demo() : pt_(), x_(){} point pt_; double x_; }; int main() { point pt; std::cout << "info of pt:( " << pt.x_ << ", " << pt.y_ << " )\n"; demo dm; std::cout << "info of dm:( " << dm.pt_.x_ << ", " << dm.pt_.y_ << ", " << dm.x_ << " )\n"; }
运行结果截图如下:
复制构造函数仅有一个类型即为该类且按引用传递的参数。复制构造函数会在以下情况被调用:
- 按值传递一个对象给函数;
- 函数返回对象时;
- 用某个类型的对象对该类型的另一个对象初始化时。
如果不手动编写复制构造函数则编译器会自动生成一个。自动生成的复制构造函数会调用每个数据成员的复制构造函数。因为手动编写的与编译器隐式生成的完全相同,所以没有显式编写的必要了。
第32讲 深入成员函数
一些颇具迷惑性的例子:
eg1:
#include <iostream> struct point { point() : x_(0.0), y_(0.0) { std::cout << "default constructor\n"; } point(double x, double y) : x_(x), y_(y) { std::cout << "constructor(" << x << ", " << y << ")\n"; } double x_; double y_; }; int main() { point pt(); }
如上示例代码,并不会输出“default constructor”,即point的默认构造函数并未执行。原因在于main函数中的“point pt()”不是定义一个point的变量pt并值初始化它,而是定义了一个无参数并且返回point类型的函数pt。
注意:值初始化只有在构造函数的初始化器中才有效。即若要值初始化x,则“x()”只能出现在x的构造函数的初始化器中。
eg2:
#include <algorithm> #include <iostream> #include <iterator> #include <vector> int main() { using namespace std; vector<int> orig; orig.insert(orig.begin(), istream_iterator<int>(cin), istream_iterator<int>()); vector<int>::iterator zero(find(orig.begin(), orig.end(), 0)); vector<int> from_zero(zero, orig.end()); copy(from_zero.begin(), from_zero.end(), ostream_iterator<int>(cout, "\n")); } #include <algorithm> #include <iostream> #include <iterator> #include <vector> int main() { using namespace std; vector<int> orig(istream_iterator<int>(cin), istream_iterator<int>()); vector<int>::iterator zero(find(orig.begin(), orig.end(), 0)); vector<int> from_zero(zero, orig.end()); copy(from_zero.begin(), from_zero.end(), ostream_iterator<int>(cout, "\n")); }
在上面两段代码中,第一段代码是正确的,但第二段存在巨大漏洞。漏洞存在于:“vector<int> orig(istream_iterator<int>(cin), istream_iterator<int>());”这里并没定义一个名为orig的变量,也没有以两个istream_iterator<int>对象为参数来调用两参数的构造函数去初始化。实际上,这里声明了一个名为orig的函数,它有两个istream_iterator<int>类型的形参:第一个形参名为cin,第二个形参没有名字。形参名字(或没有名字)两边的圆括号是多余的,会被编译器忽略掉。
避免这种情况的办法有二。一是像上面第一段代码那样,先声明一个空vector,再调用insert成员函数或copy算法来填充数据;二是用圆括号把每个参数全部括住,如下所示:
vector<int> data( (istream_iterator<int>(cin)), (istream_iterator<int>()) ); //这些额外的圆括号强制编译器将声明视为变量定义而非函数声明。
一种结构
#include <cmath> #include <iostream> struct point { point() :x_(0.0), y_(0.0) {} point(double x, double y) : x_(x), y_(y) {} point(point const& pt) : x_(pt.x_), y_(pt.y_) {} /// 与原点的距离 double distance() //const { return std::sqrt(x_*x_ + y_*y_); } /// 与x_轴夹角 double angle() const { return std::atan2(y_, x_); } /// 增加一个偏移量offset至x_,y void offset(double off) { offset(off, off); } /// 分别增加x_,y void offset(double xoff, double yoff) { x_ += xoff; y_ += yoff; } /// 比例倍增x_,y void scale(double mult) { this->scale(mult, mult); } /// 分别倍增x_,y void scale(double xmult, double ymult) { this->x_ = this->x_ * xmult; this->y_ = this->y_ * ymult; } double x_; double y_; }; void print_polar(point const& pt) { std::cout << "{ r=" << pt.distance() << ", angle=" << pt.angle() << " }\n"; } void print_cartesian(point const& pt) { std::cout << "{ x=" << pt.x_ << ", y=" << pt.y_ << " }\n"; } int main() { point p1, p2; double const pi = 3.1415926; p1.x_ = std::cos(pi / 3); p1.y_ = std::sin(pi / 3); print_polar(p1); print_cartesian(p1); p2 = p1; p2.scale(4.0); print_polar(p2); print_cartesian(p2); p2.offset(0.0, -2.0); print_polar(p2); print_cartesian(p2); }
原因在于point类的distance成员函数没有像angle成员函数那样在函数体之前参数列表之后,使用const修饰。每个成员函数都有一个隐藏参数,即引用对象本身的this。print_polar调用point.distance()、point.angle()
第33讲 访问级别
struct / class
类定义既可以由关键字struct开始,也可以由关键字class开始。两者唯一区别就在于默认的访问级别:class默认为private,struct默认为public。
关键字struct在C兼容性方面扮演了重要角色。尽管C++是有别于C的另一门语言,但许多程序还是必须与C世界交互。C++有两个重要功能,可以方便地与C交互,其中之一就是POD(即Plain Old Data,简单旧式数据)。
POD类型就是没有其他功能而仅用于存储数据的类型。例如内置类型就是POD类型,该类型没有成员函数。一个int摆在程序里,它也仅作为int完成int所能完成的功能,而那些用它完成的其他功能将取决于你的代码。
一个既无构造函数、也无重载的赋值操作符函数、而仅有公有的POD类型作为数据成员的类,也是一个POD类型。如果一个类带有私有成员、引用类型、其他非POD类型的成员,构造函数或者赋值操作符函数,那么它就不是POD。POD 类型的重要性在于,那些在C++库、第三方库或操作系统接口中遗留的C函数需要POD类型。如果发现需要调用memcpy、fwrite、ReadFileEx等众多相关函数中的一个时,就需要明确正在使用POD类。
如果用struct定义POD类,则可以达到两个目的:数据成员默认为公有的,所以不需要任何访问级别标识符,另外可以提示代码阅读者该类不是通常的类。
第35讲 继承
通过在冒号后添加基类访问级别和基类名称来定义一个派生类。派生类通过在其构造函数的初始化器中调用合适的基类构造函数来初始化它的基类。如果在初始化器列表中省略了基类或是省略了整个初始化列表,则编译器会调用基类的默认构造函数。所有基类总是在成员前,并从类树的根开始初始化。可以在它们的构造函数中加入打印消息来查看,如下代码所示:
#include <iostream> class base { public: base() {std::cout << "base::base()\n";} }; class middle:public base { public: middle() {std::cout << "middle::middle()\n";} }; class derived : public middle { public: derived() {std::cout << "derived::derived()\n";} }; int main() { derived d; }
析构函数与构造函数一样没有返回值,它的名字是在类名前加一个波浪号(~)。关于派生链中析构函数的调用顺序,示例代码如下:
#include <iostream> class base { public: base() {std::cout << "base::base()\n";} ~base() {std::cout << "base::~base()\n";} }; class middle:public base { public: middle() {std::cout << "middle::middle()\n";} ~middle() {std::cout << "middle::~middle()\n";} }; class derived : public middle { public: derived() {std::cout << "derived::derived()\n";} ~derived() {std::cout << "derived::~derived()\n";} }; int main() { derived d; }
当析构需要调用时,它会从最后派生出来的类的析构函数体开始执行,然后逐层往上调用基类的析构。如果没有手动编写的析构函数,则编译器会生成一个短小的析构函数。在执行完析构函数后,编译器会调用每个数据成员的析构函数,然后从最后派生的类开始调用所有基类的析构函数。
eg
#include <iostream> #include <vector> class base { public: base(int value) : value_(value){std::cout << "base(" << value << ")\n";} base() : value_(0) {std::cout << "base()\n";} base(base const& copy) : value_(copy.value_) { std::cout << "copy base(" << value_ << ")\n"; } ~base() {std::cout << "~base(" << value_ << ")\n";} int value() const {return value_;} base& operator++() { ++value_; return *this; } private: int value_; }; class derived : public base { public: derived(int value) : base(value) {std::cout << "derived(" << value << ")\n";} derived() : base() {std::cout << "derived()\n";} derived(derived const& copy) : base(copy) { std::cout << "copy derived(" << value() << ")\n"; } ~derived() {std::cout << "~derived(" << value() << ")\n";} }; derived make_derived() { return derived(42); } base increment(base b) { ++b; return b; } void increment_reference(base& b) { ++b; } int main() { derived d(make_derived()); base b(increment(d)); increment_reference(d); increment_reference(b); derived a(d.value() + b.value()); }
公有继承和私有继承的区别:
对于公有继承——第三方代码(不是派生类的内部或派生类的派生类)可以访问被公有继承的基类的公有成员;
对于私有继承——第三方代码不能访问被私有继承的基类的公有成员。
第36讲 虚函数
C++中的多态是依赖于虚函数实现的(其实其他语言中的多态原理也是类似的),注意多态机制仅对引用有效而对普通对象无效。如果按值传递参数、或将一个派生类的对象赋给基类变量,则会失去多态特性。因为此时基类变量存储的是一个实实在在的基类对象。当把一个子类对象复制到基类变量时,派生类在基类之上加入的数据成员会被切除。
纯虚函数
在一个类中省略某虚函数的函数体而已符号=0代替之,会使得该虚函数成为纯虚函数。纯虚函数没有可继承的实现,派生类必须覆盖此函数。至少有一个纯虚函数的类被称为“抽象类”,抽象类不允许实例化。
一条编程经验:如果一个类有虚函数,则该类一定要将其析构函数也声明为虚函数。
第38讲 声明和定义
声明会提供给编译器一些需要的基本信息,以便可以在程序中使用该名字。其中,函数声明会将函数名字、返回类型及形参类型提供给编译器。定义是一类特殊的声明,它还会提供该实体的全部实现细节。例如一个函数定义不仅包括函数声明的全部信息,而且还提供了函数体。
注意,一个类的类成员的声明或定义可以与类本身的定义独立开来。一个类定义必须声明其全部成员(当然可以把成员函数的定义也放在类定义中,但典型的风格是在类定义之外定义成员函数)。
类定义外的成员函数定义比较像普通函数定义,但有一些区别。必须先声明后定义,即在源文件中,一个成员函数定义必须出现在声明该成员函数的类定义之后。定义中省略virtual说明符,开头必须为类名字、范围说明符(::),再接函数名字,以告诉编译器这是哪一个类的成员函数定义。函数体的写法则与函数定义是否在类定义内无关。如下示例代码:
class rational { public: rational(); rational(int num); rational(int num, int den); void assign(int num, int den); int numerator() const; int denominator() const; rational& operator=(int num); private: void reduce(); int numerator_; int denominator_; }; rational::rational() : numerator_(0), denominator_(1) {} rational::rational(int num) : numerator_(num), denominator_(1) {} rational::rational(int num, int den) : numerator_(num), denominator_(den) { reduce(); } void rational::assign(int num, int den) { numerator_ = num; denominator_ = den; reduce(); } void rational::reduce() { assert(denominator_ != 0); if (denominator_ < 0) { denominator_ = -denominator_; numerator_ = -numerator_; } int div(gcd(numerator_, denominator_)); numerator_ = numerator_ / div; denominator_ = denominator_ / div; } int rational::numerator() const { return numerator_; } int rational::denominator() const { return denominator_; } rational& rational::operator=(int num) { numerator_ = num; denominator_ = 1; return *this; }
类似于rational::operator=、rational::numerator、rational::rational等在C++中称为“限定名”。
关于类定义中的内联函数
1. 如果在类定义体中定义一个函数(注意这里是定义而非简单的声明),则该函数自动成为内联的,且该函数前的inline关键字写与不写都不会报错;
2. 如果成员函数的声明和定义分开,则在声明或定义任意一处加上inline即可声明该函数为内联的,建议在两处都加上以便于阅读;
3. 要注意inline关键字仅仅是个提示,编译器可以忽略它。
普通数据成员拥有声明而非定义,函数或语句块类的局部变量拥有定义而并不与声明分开。
命名对象的定义指示编译器分配存储对象值的内存及生成初始化对象的必要代码。有些对象实际上是子对象,既不是完全自主拥有的对象,它的内存及生命周期由包含它的完整对象所指定。也就是说,数据成员没有自己的定义,而是由于该类型对象的定义导致了分配存储对象全部数据成员的内存。
定义一个块内的局部变量,要指明对象类型、名称、是否是const及初始值(如果有)。对于局部变量,不存在不包含定义的声明,但有其他种类的声明(如下文所述)。可以为某局部变量声明一个局部引用,作为改变量的同义词。声明作为引用的新名字与引用相同,但需要用一个已存在的对象对其初始化。
#include <iostream> int main() { int answer(42); //声明一个命名对象 int& ref(answer); //声明一个引用,名字为ref ref = 10; std::cout << answer << '\n'; int const& cent(ref * 10); //声明 std::cout << cent << '\n'; }
一个局部引用既未分配内存,也未运行初始化序列,因此它不是定义。该引用声明为一个老对象创建一个新名字。通常使用局部引用为一个由长表达式生成的对象创建一个短名字,或者把一个表达式保存在const引用中以便多次使用其结果。结论是,局部引用都不是定义而是声明。
静态/自动变量
局部变量是自动类型的,即当进入到一个函数或局部块(复合语句)时再分配内存、构造对象,而当函数返回或控制离开块时则析构对象并释放内存。因为所有的自动类型变量都是在栈上分配的,所以不必关心内存的分配及释放(这些工作一般由主机平台上正常的函数调用指令来处理)。
关键字static指示编译器该变量是静态类型的而非自动。静态变量的内存既不是自动分配也不是分配在栈上。所有的静态变量会分配在一个长久保留的地方(估计是堆上)。
#include <iostream> int generate_id() { static int counter(0); ++counter; return counter; } int main() { for(int i = 0; i != 10; ++i) std::cout << generate_id() << '\n'; }
在所有函数之外声明的变量,是为隐式的static变量(因为它不在任何函数或块之内,且不能再添加显式的static关键字来修饰它)。
#include <iostream> int counter; int generate_id() { ++counter; return counter; } int main() { for(int i = 0; i != 10; ++i) std::cout << generate_id() << '\n'; }
和自动变量不同,对于没有初始化序列的静态变量,无论是否为内置类型,均会被填充为零;如果类型为类且该类有自定义构造函数,则会调用该类的默认构造函数进行初始化。所以上例中的counter既可以指定初始化序列也可不指定。
使用C++的静态变量的一个困难就是,程序难以控制它们的初始化时间。C++标准提供两个基本保证:
1. 静态对象会按照在源文件中的出现次序依次进行初始化;
2. 静态对象会在被main()使用之前进行初始化,或者在main()调用任何函数前进行初始化。
定义一个静态数据成员与定义其他全局变量相同,但要在成员名前加上类名进行限定。仅在数据成员声明中(即类体中)使用static关键字,而定义中(不再在类体中)不需要。因为静态数据成员不属于类的实例,所以不能把它写在构造函数的初始化列表中,正确的方法是在类定义外,像对待一个全局变量那样来初始化它,且别忘记在成员名前加上类名。
#include <iostream> class SomeClass { public: SomeClass(); static int iVar; }; SomeClass::SomeClass() { /* do some initialization here. */} int SomeClass::iVar = 12; int main() { std::cout << SomeClass::iVar << '\n'; }
关于静态数据成员,有一点很具有迷惑性,即static与const的连用。仅有整形的static const数据成员可以在类定义中指定它们的初始值,并作为数据成员声明的一部分。但请注意,初始值不会把声明变成定义,因此还需要在类定义外来定义数据成员。如果在声明中提供了一个值,就可以在程序其他任何需要整形常量的地方把该static const数据成员当做一个常量来用。
#include <iostream> class SomeClass { public: SomeClass(); static int iVar; static const int iXX =13; }; SomeClass::SomeClass() { /* do some initialization here. */} int SomeClass::iVar = 12; const int iXX = 23; int main() { std::cout << SomeClass::iVar << '\n' << SomeClass::iXX << '\n'; }
奇怪的是,在类体中声明的初始值似乎就是该静态常量的值了,而其后的定义会被忽略……
第39讲 使用多个源文件
需要注意,在调用函数时,编译器仅需要函数的声明而不需要定义。如果定义一个全局变量并在多个文件中使用,则需要在每个使用该变量的文件中加入一个声明。使用关键字extern并加上变量类型和名字就可以声明一个全局变量。
//file math.hpp #ifndef MATH_HPP_ #define MATH_HPP_ extern double cosnt pi; #endif //file math.cpp #include "math.hpp" double const pi(3.1415926535897932);
通常的做法是在一个头文件中声明函数,在另一个源文件中定义这些函数并链接到程序中。这对于自由函数和成员函数都能正常工作,但内联函数的规则和普通成员函数不同。即:头文件包含非内联函数的声明及内联函数的定义。分开的源文件仅定义非内联函数。因为调用内联函数的源文件还必须晓得该函数的定义。
“一份定义”原则(One Definition Rule,即ODR):编译器允许每个源文件中有一份类、函数或对象的定义,且在整个程序中只能有一份函数或全局对象的定义。可以在多个源文件中定义某个类,只要该定义在所有的源文件中相同即可。
第40讲 函数对象
函数调用操作符——让一个对象的行为类似于一个函数。该操作符的重装方式和其他操作符一样,名字为operator()(注意这里的括号是名字的一部分,就像operator+中的“+”符号一样)。operator ()可以有任意多个形参,返回类型也任意。
//file generate_id.hpp #ifndef GENERATE_ID_HPP_ #define GENERATE_ID_HPP_ class generate_id { public: generate_id() : counter_(0) {} long operator()(); private: short counter_; static short prefix_; static long int const max_counter_= 32767; }; #endif //file generate_id.cpp #include "generate_id.hpp" short generate_id::prefix_(1); long generate_id::operator()() { if(counter_ == max_counter_) counter_ = 0; else ++counter_; return prefix_ * (max_counter_ + 1) + counter_; } //file main.cpp #include <iostream> #include "generate_id.hpp" int main() { generate_id gen; for(int i = 0; i < 4; ++i) std::cout << gen() << '\n'; //gen是generate_id的一个对象,因为generate_id重装了函数调用操作符,所以可以像使用普通函数那样来使用gen }
如上例所示。要使用函数调用操作符,首先要声明一个重装了operator()的类的对象,然后就可以把该对象的名字当作一个普通函数名字一样来使用,并像普通函数一样传递实参。编译器会把这种对该对象名字的使用当作一个函数并调用相应的函数调用操作符。
函数对象(function object):就是重载了函数调用操作符的类的对象,或称为函子(functor)。(有时在非正式场合,也用函数对象指代这个类)不必为了使用一个函数对象而编写一个全新的类,有时可以重复利用已有的类的成员函数。标准库中有一个函数mem_fun_ref(<functional>),它可以把一个成员函数包装在一个特殊的函子中,以便在需要函数对象的地方能够使用该成员函数。
// mem_fun_ref example #include <iostream> #include <functional> #include <vector> #include <algorithm> #include <string> using namespace std; int main () { vector<string> numbers; numbers.push_back("one"); numbers.push_back("two"); numbers.push_back("three"); numbers.push_back("four"); numbers.push_back("five"); vector <int> lengths (numbers.size()); transform (numbers.begin(), numbers.end(), lengths.begin(), mem_fun_ref(&string::length)); for (int i=0; i<5; i++) cout << numbers[i] << " has " << lengths[i] << " letters.\n"; return 0; }
其中关键在于对mem_fun_ref的调用,它唯一的参数就是一个成员函数的地址,在上例中就是string类型的成员函数length的地址(用取地址符&来获取)。mem_fun_ref函数使用该成员函数来创建一个函子,该函子有一个类型为string的形参,它的函数调用操作符会调用成员函数length()。mem_fun_ref实际的实现方式和使用的库有关,而其生成的函子类基本和下面代码类似:
class mem_fun_ref_class { public: int operator()(std::string const& obj) const { return obj.length(); } };
实际的类名字和实现细节相关,但它不会和用户代码的名字相冲突。在mem_fun_ref的实参中需要提供形参的类型(std::string)和成员函数名字(length),而编译器会确定其他内容,包括返回值类型、函数是否为const等。如果成员函数length不是const,则函子类就没有const函数调用操作符和obj形参。
第41讲 有用的算法
常用的算法(所有以下算法的功能和用法可参考http://www.cplusplus.com)
搜索
- find
- find_if
- min
- max
- search
- binary_search
- lower_bound
- upper_bound
- distance
比较
- equal
- mismatch
- lexicographical_compare
重新组织数据
- sort
- merge
- replace
- replace_if
- random_shuffle
- generate
- generate_n
- fill
- fill_n
- transform
复制数据
- reverse
- reverse_copy
- copy_backward
注意,标准库没有copy_if
删除元素
- remove
- remove_if
- remove_copy
- remove_copy_if
- unique
- unique_copy
第42讲 迭代器
C++中有五种类型的迭代器:输入迭代器、输出迭代器、前向迭代器、双向迭代器、随机访问迭代器。输入与输出迭代器的功能最少,而随机访问迭代器的功能最多。在任何使用功能少的迭代器的地方均可以使用功能更多的迭代器替代。
注意:所有迭代器均应确保不超出区间末尾,也不引用到区间起始之前。
输入迭代器
只支持输入(只能读不能写),每次迭代通过*操作符进行读操作,通过++操作符将位置递增到下一个输入数据项。输入迭代器支持相等性的比较,只有将一个迭代器与末端迭代器进行比较才是有意义的。因为比较的是迭代器本身的值,而非其引用的数据项。例子:istream_iterator、begin()的返回值。
输出迭代器
只支持输出(即不能通过它来读数据),每次迭代通过*操作符进行写操作,通过++操作符将位置递增到下一个输出数据项。不能对输出迭代器比较相等性与不相等性。使用时,必须确保迭代器真实写入的地方有足够的空间存储输出数据整体,任何错误都将导致不确定行为。例子:ostream_iterator、begin()的返回值。
前向迭代器
除了具有输入/出迭代器的全部功能外,还有更多的功能。可以自由的读写一个迭代器的数据项(使用*操作符),并且可以随意地操作任意多次(输入输出迭代器每次只能引用一次数据项??);++操作符可将其递增到下一个数据项;==与!=操作符用于比较迭代器,检查它们是否引用相同的数据项或是达到末端。所有容器的迭代器都满足前向迭代器的要求。前向迭代器不支持+n的位移操作。例子:二分搜索算法需要使用前向迭代器指定输入区间,因为它可能对某一数据项引用一次以上。
双向迭代器
支持前向迭代器的所有功能,而且支持--操作符,使得迭代器移回到之前所在的数据项。几乎所有容器的迭代器都满足双向迭代器的要求。双向迭代器不支持+n或-n的位移操作。例子:reverse、reverse_copy
随机访问迭代器
它具有所有迭代器的所有功能,此外可以通过加上或减去整数的方式使迭代器移动任意量。如果两个随机访问迭代器引用相同的对象序列,则:
1. 可以直接对它们相减,以获得它们之间的距离;
2. 比较它们的相等性;
3. 还可使用任何关系操作符,例如a<b意味着a引用的数据项比b引用的数据项出现得早。
例子:sort算法需要随机访问迭代器;vector提供随机访问迭代器,但并不是所有的容器都提供,例如实现双向链表的list容器只提供双向迭代器(由于不能对其使用sort算法,因此list有自己的sort成员函数)。
实际项目中,建议使用容器的empty()成员函数来判断容器是否为空,而不是测试begin()==end()是否成立。
容器的迭代器类型总是以iterator命名。该名字是一个嵌套成员,在引用迭代器名字时需要带有其容器类型前缀。
std::map<std::string, int>::iterator map_iter;
std::vector<int>::iterator vec_iter;
可以调用advance函数(在<iterator>中声明)将迭代器递增到新的位置。第一个参数是准备递增的迭代器,第二个是位移数量(对于双向/随机访问迭代器,该参数还可以是负值,用于回退)。可以递增输入迭代器,但不能递增输出迭代器。
const_iterator
可以自由的修改迭代器本身的值,但迭代器所引用的对象是const的。(注意,const iterator xxx,表示的是迭代器xxx本身是const的)每个容器实际上有两个不同的begin()函数:一个是const成员函数,返回const_iterator;另一个不是const成员函数,返回普通iterator。与任何const或非const成员函数一样,编译器根据容器本身是否为const来选择二者之一:如果容器不是const的,则得到的是非const版本的begin(),它返回普通的iterator,可以任意修改其值;如果容器是const的,则得到的是const版本的begin(),它返回const_iterator,可以防止修改容器的内容。
一些专用迭代器
<iterator>头文件定义了许多有用的专用迭代器,如下所示。
- back_inserter(SomeContainer& x)
- front_inserter(SomeContainer& x)
- inserter(SomeContainer& x, InsertPosition i)
以上三个inserter函数都返回一个输出迭代器。
对于back_inserter,每次对解引用后的迭代器赋值时,它会调用容器的push_back成员函数,将值插入容器末尾;
对于front_inserter,每次对解引用后的迭代器赋值时,它会调用容器的push_front成员函数,将值插入容器的起始;
对于inserter,返回调用容器的insert成员函数的输出迭代器,insert成员函数需要一个iterator参数来指定插入值的位置(即inserter迭代器最初传入的第二个参数),每次插入后它更新内部的迭代器以保证随后的插入项进入了后续的位置(即inserter完成了正确的操作)。
- istream_iterator
- ostream_iterator
istream_iterator是一个在解引用时从流中提取值的输入迭代器。在不带参数时,istream_iterator的构造函数创建一个流末端迭代器。当输入操作失败时,迭代器会等于流末端迭代器。
ostream_iterator是一个输出迭代器,它的构造函数接受一个输出流以及一个可选的字符串参数。对解引用后的迭代器赋值会将值写到输出流中,后面可选地追加(来自构造函数的)字符串。
- reverse_iterator
它对已存在的迭代器(称为基迭代器)进行适配,其中基迭代器必须是双向(或随机访问)迭代器。当翻转迭代器前进(++)时,对应的基迭代器回退(--)。支持双向迭代器的容器具有rbegin()与rend()成员函数,它们返回翻转迭代器。
rbegin()函数返回指向容器中最后一个元素的翻转迭代器;rend()返回一个特殊的翻转迭代器,它指向容器起始元素之前的一个位置。可以将区间[rbegin(), rend())看做普通的迭代器区间,它以翻转后的顺序表示容器中的值。
C++并不允许迭代器指向“起始元素之前的一个位置”,因此翻转迭代器的实现稍微有些特别。通常来说实现细节无关紧要,但是reverse_iterator在其返回基迭代器的base()成员参数中暴露了具体细节。
#include <algorithm> #include <cassert> #include <iostream> #include <ostream> #include "data.hpp" #include "sequence.hpp" int main() { intvector data(10); std::generate(data.begin(), data.end(), sequence(1)); write_data(data); // prints { 1 2 3 4 5 6 7 8 9 10 } intvector::iterator iter(data.begin()); iter = iter + 5; // iter is random access std::cout << *iter << '\n'; // prints 5 intvector::reverse_iterator rev(data.rbegin()); std::cout << *rev << '\n'; // prints 10 rev = rev + 4; // rev is also random access std::cout << *rev << '\n'; // prints 6 std::cout << *rev.base() << '\n'; // prints 7 std::cout << *data.rend().base() << '\n'; // prints 0 assert(data.rbegin().base() == data.end()); assert(data.rend().base() == data.begin()); }
基迭代器 总是指向翻转迭代器之后的一个位置。这就是在不允许的情况下,rend()也能指向“起始元素之前的一个位置”的技巧。rend()迭代器实际上具有一个指向容器首个数据项的基迭代器,同时reverse_iterator对*操作符的实现做了稍微的调整:接受基迭代器,回退一个位置,然后对基迭代器解引用。
第43讲 异常
异常
习惯上异常继承自标准库提供的std::exception类或它的几种子类之一。第三方库有时也引入自己的异常基类。
捕获异常
当异常对象的类型与异常处理程序(两个相邻catch之间的代码)声明的对象的类型匹配时,处理程序认为这是一组匹配项,并将异常对象复制到处理程序对象。处理程序对象的声明也可以是一个引用,此时就可以节省额外的异常对象副本开销。多数处理程序不需要修改异常对象,因此处理程序声明通常是const的引用。
异常处理系统销毁try块中抛出异常之前的语句创建的所有对象,然后将控制移交给处理程序(catch块)。处理程序主体正常运行之后,将控制恢复给整个try-catch块末尾之后的语句(即最后一个异常处理程序后面的语句)。处理程序类型被依次匹配,首个匹配项将匹配成功,因此应该将最特殊的类型放在前面,而把基类置后。
catch(...)是匹配任意异常的全捕获(catch-all)处理程序,如果要使用,必须将其放在最后。惯用法如下演示:
... catch(...) { std::cout << "unknow exception type. program terminating.\n"; std::terminate(); }
因为处理程序不晓得异常的类型,所以没有办法访问该异常对象,全捕获处理程序打印一条消息,然后调用std::terminate()(在<exception>中声明)会调用std::abort()将程序终止。
对于标准库的异常,只要catch(std::exception ex)就可以实现全捕获;但对于非标准库的异常,就可以通过catch(...)来全捕获了。当然,std::exception和...可以同时捕获,只要...在最后就行。
抛出异常
通过throw表达式来抛出异常,该表达式由throw关键字及其后接的异常对象表达式组成。标准异常均带有一个string参数,它是what()成员函数的返回值。
throw std::out_of_range("index out of range");
标准库用于其本身的异常消息是由实现决定的,因此不能依赖它们提供任何有用的信息。
可以在任何允许以某种方式使用表达式的地方抛出异常。throw表达式的类型是void,代表它没有类型。void类型不能作为算术或比较等操作符的操作数。因此,在现实中throw表达式通常只用于表达式语句本身。
可以在异常处理程序内部再次抛出异常。可以抛出相同的异常对象或全新的异常。如果抛出相同的异常,可以使用不带任何表达式的throw关键字。
catch(std::out_of_range const& ex) { std::cout << "index out of range\n" throw; //再次抛出相同的异常,即ex }
一种常见的情况是在全捕获处理程序内部重新抛出异常。全捕获处理程序执行一些重要的清理工作,然后把异常传送给能够处理它的程序。
如果抛出一个新异常,异常处理系统会正常接受。控制块立即离开try-catch块,因此同一个处理程序不能在捕获新异常。
程序栈(执行栈)
程序调用函数时,将一个帧压入栈中。帧内包含诸如指令指针和函数的寄存器、参数等信息,还有可能包含为函数返回值准备的内存。帧是动态的,它表现的是函数的调用和程序的控制流,而非源代码的静态表示。因此函数可以调用自身,每次调用在栈中压入一个新帧,每个帧具有各自独立的所有函数参数和局部变量的副本。
函数开始执行时,有可能在栈内为局部变量分配一些内存。每个局部作用域在栈内压一个新帧。(编译器有可能对此进行优化,对某些局部作用域甚至整个函数使用物理帧,但从概念上说,下文仍然适用。)
函数执行时,通常会构造各种对象:函数参数、局部变量、临时对象等。编译器记录了函数必须创建的所有对象,以便在函数返回时将它们正确销毁。局部对象按照与创建时相反的顺序销毁。
程序抛出异常时,正常的控制流停止,由C++异常处理机制接管控制。异常对象被复制到执行栈之外的安全区域。异常处理代码在栈中查找try语句。找到try语句之后,它依次检查每个异常处理程序的类型,以便找到匹配项。如果没有匹配项,它会在栈中更远处查找下一个try语句,直到找到匹配的异常处理程序或检查完这个帧才停止搜索。
在找到匹配项之后,它将该帧从执行栈中弹出,对弹出的帧内所有的局部对象调用析构函数,然后继续弹出帧,直至到达异常处理程序。从栈中弹出栈也称为栈展开。
栈展开之后,由抛出的异常对象初始化处理程序的异常对象,然后程序执行catch块的主体。在catch块的主体正常退出后,释放异常对象,程序继续执行同组最后一个catch块之后的语句。
如果处理程序抛出了异常,对匹配处理程序的搜索会重新启动。处理程序不能处理自身抛出的异常,与它在同一个try语句中同组的任何处理程序也不能处理。
如果没有处理程序与异常对象的类型匹配,就会调用std::terminate函数,它将使程序终止。有些实现在调用terminate之前会弹出栈,释放局部对象,有些实现则不这样做。
范例:可视化异常
#include <exception> #include <iostream> #include <istream> #include <ostream> #include <string> class visual { public: visual(std::string const& what): id_(serial_), what_(what) { ++serial_; print(""); } visual(visual const& ex): id_(ex.id_), what_(ex.what_) { print("copy "); } ~visual() { print("~"); } void print(std::string const& label) const { std::cout << label << " visual(" << what_ << ": " << id_ << ")\n"; } private: static int serial_; int const id_; std::string const what_; }; int visual::serial_(0); void count_down(int n) { std::cout << "start count_down(" << n << ")\n"; visual v("count_down local"); try { if (n == 3) throw visual("exception"); else if (n > 0) count_down(n - 1); } catch (visual ex) { ex.print("catch "); throw; } std::cout << "end count_down(" << n << ")\n"; } int main() { try { count_down(2); count_down(4); } catch (visual const ex) { ex.print("catch "); } std::cout << "All done!\n"; }
输出结果如下:
start count_down(2) visual(count_down local: 0) start count_down(1) visual(count_down local: 1) start count_down(0) visual(count_down local: 2) end count_down(0) ~visual(count_down local: 2) end count_down(1) ~visual(count_down local: 1) end count_down(2) ~visual(count_down local: 0) start count_down(4) visual(count_down local: 3) start count_down(3) visual(count_down local: 4) visual(exception: 5) copy visual(exception: 5) catch visual(exception: 5) ~visual(exception: 5) ~visual(count_down local: 4) copy visual(exception: 5) catch visual(exception: 5) ~visual(exception: 5) ~visual(count_down local: 3) copy visual(exception: 5) catch visual(exception: 5) ~visual(exception: 5) ~visual(exception: 5) All done!
标准异常
标准库定义了几种标准异常类型。基类exception声明在<exception>头文件中,多数其他异常类定义在<stdexcept>头文件中。如果想定义自己的异常类,建议从<stdexcept>里某种标准异常派生。
标准异常分为以下两大类(分别具有两个直接派生自exception的基类)。
- 运行时错误(std::runtime_error),是指仅通过检查源代码不能检测和预防的异常。它们由可预料而不可预防的条件引发。
- 逻辑错误(std::logic_error)是程序员的错误引发的结果,它表示的是违反先决条件、无效函数参数以及程序员应当在代码中预防的其他错误。
I/O异常
状态位很重要,但反复检查状态位会很麻烦。(状态位是一种简单、过时而懒惰的做法)好在C++为程序员提供了一种能够获得I/O安全而无需太多额外工作的途径:流会在I/O失败时抛出异常。
每个流除了具有状态位,还有一个异常掩码(exception mask)。异常掩码告诉流,如果相应的状态位的值发生改变,则要抛出异常。
#include <ios> #include <iostream> int main() { std::cin.exceptions(std::ios_base::badbit); std::cout.exceptions(std::ios_base::badbit); int x(0); try { while(std::cin >> x) std::cout << x << '\n'; if(not std::cin.eof()) std::cerr << "Invalid integer input. Program terminated.\n"; } catch(std::ios_base::failure const& ex) { std::cerr << "Major I/O failure! Program terminated.\n"; std::terminate(); } }
注意,在有些情况下区分正常输出(输出到cout)和错误输出(输出到cerr)很重要,因为cout可能发生严重错误(比如磁盘满),因此试图将错误消息写入cout有可能是徒劳的。作为替代,程序将消息写入cerr。虽然写入cerr也不一定能保证工作正常,但至少提供一个机会。例如用户可能将标准输出重定向到文件(由此引发磁盘满的错误),而此时错误输出可以显示在控制台上。
当输入流到达输入的末尾时,会对其状态掩码设置eofbit。(尽管也可以在异常掩码中设置这个位,但这样做找不到任何理由)如果输入操作没有从流中读入任何有用的内容,流将设置failbit。流没有读入任何内容的最常见原因是文件结束(设置了eofbit)或输入格式错误(比如:要求读入数字时,输入流中出现文本)。可以在异常掩码中设置failbit,但多数程序依赖于普通的程序逻辑对输入流状态进行测试。异常机制只用于异常的情况,而文件结束是读取到文件尾时发生的一种正常情况。
当failbit设置时,读取输入的循环结束,但仍需要进一步测试failbit的设置是因为正常的文件结束还是由错误的输入引发。如果同时eofbit也被设置,则可以确定是流到达了文件末尾,否则failbit一定是由错误的输入引发的。
第44讲 更多操作符
条件操作符:condition ? true-part : false-part
true-part和false-part是具有相同或兼容类型的表达式,编译器能够自动将一种类型转换为另一种,以确保表达式整体具有定义良好的类型。例如:std::cout << std::fixed << ( x > 0 ? 10 : 42.24) << '\n'; 该语句在x为正数时打印10.000000。
注意:条件操作符的优先级比赋值的优先级更低,所以可以有:x = test ? 42: 24; 不建议重载and和or操作符。因为这样做就会失去它们的重要优势:短路就值。
第46讲 函数模板
1.1 什么是函数模板
函数模板就是一个按照提供的模板实参来生成函数的样式。函数模板可以用来创建一个通用的函数,以支持多种不同类型的形参,避免重载函数的函数体重复设计。它的最大特点是把函数使用的数据类型作为参数。
函数模板的最大特点是:编译器能自动地从函数实参推导出模板实参(例如 T),如下例所示:
例1
#include <iostream> template<class T> T absval(T x) { if(x < 0) return -x; else return x; } int main() { std::cout << absval(-42) << '\n'; std::cout << absval(-42.12) << '\n'; std::cout << absval(-42) << '\n'; std::cout << absval(-42L) << '\n'; }
1.2 函数模板的使用
虽然编译器可以根据实参来推断模板实参,但并不是时时尽如人意。
例2
#include <iostream> template<class T> T max(T a, T b) { return (a >= b) ? a : b; } int main() { int x(42); long y(25); std::cout << max(x, y) << '\n'; //std::cout << max<long>(x, y) << '\n'; }
编译以上代码时,会报错,因为编译器不知道该给T匹配int还是long类型。所以,在这种情况下,需要给编译器提示,如上例中的注释部分。max<long>指示编译器将max<T>中的T当成long。
另外,可以从右至左依次省略那些可以被编译器推导出的实参(如例3所示)。若编译器可以推导出全部实参,则可以把实参连同尖括号一起省略(如例1所示)。
例3
template<class T, class R> T convert(R const& r) { return static_cast<T>(r.numerator()) / r.denominator(); } rational r(42, 10); //若写成convert(r),则编译器无法获知T到底是什么类型,所以需给以提示 //而r的类型可以从其定义推断出来,故有如下的写法 double x(convert<double>(r));
1.3 编写函数模板
对于函数模板,它的声明与其定义应在同一个源文件中,以便使得编译器在看到函数模板的调用时能看到该模板的定义。
例4
#ifndef ABSVAL_HPP_ #define ABSVAL_HPP_ template<class T> T absval(T x) { if(x < 0) return -x; else return x; } #endif
1.4 模板形参
模板形参,即template<class T>部分,其中关键字class可以用typename来代替。使用typename的好处是避免了对非class类型的误解,缺点是typename在模板上下文中还有其他意义。但不论选用class或typename均可。
template<class T>中的T即是模板形参,T也可以用其它符号代替,但习惯上当只有一个形参时用符号T。如果有两个或以上的模板形参时,就会引入新的标识符了,如:
template<class InputIterator, class OutputIterator> OutputIterator copy(InputIterator start, InputIterator end, OutputIterator result) { for(; start != end; ++start, ++result) *result = *start; return result; }
1.5 模板实参
通常编译器可以自动从函数实参推导出模板实参,但这并非绝对,因为有些时候函数实参的类型具有兼容性(即一个参数的类型兼容另一个参数的类型,例如int和double)。这个时候需要显式的告知编译器,如下例所示。
#include <iostream> template<class T> T my_min1(T a, T b) { if(a < b) return a; else return b; } long my_min2(long a, long b) { if(a < b) return a; else return b; } int main() { int x(10); long y(20); std::cout << my_min2(x, y) << '\n'; std::cout << my_min1(x, y) << '\n'; //error }
编译器在处理my_min2时会将x从int转换为long。但是编译器在处理模板时,不会进行任何自动类型转换。因为不能确定用户期望的模板形参是函数的第一个还是第二个实参的类型。本例中,可以通过在尖括号内指明需要的模板形参类型来指导编译器。
int x(10); long y(20); std::cout << my_min2(x, y) << '\n'; std::cout << my_min1(x, y) << '\n'; //在尖括号中指明模板形参的期望类型
如果模板有多个实参,则用逗号分隔之,如下例所示。
#include <iostream> #include <istream> #include <ostream> template<class T, class U> U input_sum(std::istream& in) { T x; U sum(0); while(in >> x) sum += x; return sum; } int main() { //因为input_sum函数的实参(std::cin)并未使用数据和累加操作符的类型(即T和U),所以编译器不能推导出 //模板形参,因而需要显式指明。 long sum(input_sum<int, long>(std::cin)); std::cout << sum << '\n'; }
1.6 函数模板的声明与定义
在使用一个函数模板之前,编译器不仅需要函数模板的声明,通常还需要完整的函数模板定义。换言之,如果在某个头文件中定义了一个模板,则该文件必须同时包含该函数模板的内容。
例如,如果许多工程都要共享gcd函数(求两个数的最大公约数),那一般会把gcd的函数声明放到一个头文件中,比如gcd.hpp,而把完整的函数定义放到另一个文件中,比如gcd.cpp。
但如果吧gcd变为一个函数模板,则通常要把定义放到头文件中。如下代码所示:
// file: gcd.hpp #ifndef GCD_HPP_ #define GCD_HPP_ #include <algorithm> template<class T> T gcd(T a, T b) { T n(std::max(a, b)); T m(std::min(a, b)); while(m != 0) { T tmp(n % m); n = m; m = tmp; } return n; } #endif
编译器在为函数模板生成具体的函数时,比如gcd<int>或gcd<long>,它需要该函数模板的内容。当然有些奇葩的编译器允许通过使用关键字export,把模板定义与声明分开,但大多数编译器并不这么做。
1.7 成员函数模板
函数模板也可以是一个成员函数。例如将rational类中的as_long_double/as_double/as_float函数实现为一个模板以提供类似的功能:
class rational { public: rational() : numerator_(0), denominator_(1){} .... template<class T> T convert() const { return static_cast<T>(numerator()) / static_cast<T>(denominator()); } };
第47讲 类模板
1.1 什么是类模板
类模板就是一个按照提供的模板实参来生成类型的样式,例如point<int>。使用关键字template开始一个类模板,类模板形式:
template<class T> class point { public: point(T x, T y) : x_(x), y_(y) {} T x() const {return x_;} T y() const {return y_;} void move_to(T x, T y); void move_by(T x, T y); private: T x_, y_; };
类模板的成员函数本身也是函数模板,并与类模板使用相同的模板形参,但是,是由类来接受实参而非函数,如point<T>::move_to函数所示。与函数模板不同,编译器不能推导出类模板的模板实参,需要显式指出(即在尖括号中显式地提供实参)。【注意】不同的模板实参(如point<T>中的T)会导致编译器生成不同的类型,例如point<int>、point<long>就是两种不同的类型;而相同的模板实参,编译器只生成一份类型定义。
混合类型
int和long类型之间可以相互赋值(忽略溢出问题),rational<int>和rational<long>之间亦可(同样忽略溢出问题)。
template<class T> template<class U> rational<T>& rational<T>::operator=(rational<U> cosnt& rhs) {...}
第48讲 模板特化
当使用一个模板时,称为实例化该模板;模板实例,是编译器将模板实参应用于模板定义而生成的具体的函数或类。模板实例也被称为特化,例如rational<int>就是模板rational<>的一个特化。因此,特化是一个模板对于一个特定的模板实参集合的实现。由编译器自动生成的特化(模板实例)称为隐式特化;如果是手工定义的特化,则称之为显式特化(显式特化亦称为完全特化)。隐式特化由编译器自动完成不需程序员干预,而显式特化则如下所示。
例1
template<> class point<double> { public: point<double>(double x, double y) : x_(x), y_(y) {} point<double>() : x_(0.0), y_(0.0 ){ std::cout << "point<double>() specialization\n";} double x() const {return x_;} double y() const {return y_;} void move_to(double x, double y) { x_ = x; y_ = y;} void move_by(double x, double y) { x_ += x; y_ += y;} private: double x_; double y_; };
用template<>开始一个显式特化(注意尖括号内为空),以通知编译器接下来编写的是显示特化,随后是定义。注意类名是如何成为特化模板名字的:point<int>,编译器以此确定特化的内容。在特化某模板之前,需要提供该类模板的名称或其完整定义。通常在一个头文件中,按照正确的顺序先声明类模板,然后编写其特化。
如果模板有多个模板形参(例如point<class T, class U>),则在特化时须为每个实参提供特定的值。该显示特化会为该模板实参(或多个实参)完全覆盖原有的模板声明,尽管习惯上point<int>应该定义和原有模板point<>完全相同的成员,但编译器并未强加此限制。
关键字typeid返回用来描述某个类型的typeinfo对象(在<typeinfo>中定义),把typeid应用于表达式中则可以获得表达式类型的信息。但要注意这并不是反射机制。
//特化标准命名空间中的模板类std::less<>,该模板类在<functional>头文件中 namespace std { template<> struct less<person> { bool operator()(person const& a, person const& b) const { return a.name() < b.name(); } }; }
对于一个函数调用,编译器会优先检查非模板函数,然后检查模板函数,而如果找不到哪个非模板函数的形参类型兼容实参类型时,则会使用模板函数。
第49讲 部分特化
进行显式特化时,必须为每个模板形参指定一个相对应的模板实参,而不能保留模板头的任何形参。但有时需要仅指定某些模板实参而仍然保留头部的其他某个或某些模板形参。so,部分特化出现。
如果某个模板特化需要某些、但不是全部的模板实参,则这种模板特化被称为“部分特化”。有些程序员为了区分部分特化,就把显式特化称为完全特化,然而部分特化实际上也是显式的,即显式特化包含部分/完全特化两种类型。
注意:不允许对函数模板进行部分特化。(可以使用重载来代替)
值模板形参
模板的形参一般为类型,但也可以是一个值。使用一个类型和一个可定制的名字,来声明值模板形参,与声明函数参数相同。值模板形参必须为bool、char、int等的编译期常量,而浮点类型和类则不可以。
template<class T, int N> class fixed { public: typedef T value_type; static const T places = N; static const T places10; ... };
第50讲 名字和名字空间
namespace [identifier] { //statement here } //no ; here
名字空间的定义必须在所有函数之外。
名字空间定义可以不连续,即同一个名字空间可以分成多个块。因此可以把同一个名字空间的定义分别放在不同的头文件中,且每一部分的定义都会向同一个名字空间中增加名字。如果在某个名字空间中声明了某个实体(例如一个函数)但没有定义它,则可以在如下方法中选择一个来定义该实体:
- 使用相同的,或者另一个名字空间的定义中定义该实体。
- 在名字空间之外定义实体,该实体名字前加上名字空间名字和作用域操作符(::)。
全局名字空间(global namespace)
简单地说,类似于Java中的终极类Object一样,C++中所有显式的名字空间都在全局名字空间中,全局名字空间并无显式的标示符。在任何函数外声明的所有实体都在某个名字空间中——或是显式名字空间,或是全局名字空间。(因此,在函数之外的名字被称为处于 名字空间作用域 中)(某个名字具有)全局作用域是指在隐式全局名字空间中声明的名字(注意此时这个名字具有全局作用域),即在任何显式名字空间之外。
要限定全局名字,需要在名字前加上一个作用域操作符前缀。如:::exploring_cpp::numeric::rational<int> half(1, 2);
名字空间std
标准库使用名字空间std,程序员不被允许在名字空间std中定义任何名字,但可以特化定义于std中的模板。
注意,C++标准库从C标准库中继承了一些函数、类型和对象。由C派生而来的头文件的名字都以一个额外的字母C开头,从而可以识别它们。例如,C++的<cmath>就等价于C的头文件<math.h>。一些如EOF之类的C名字并不遵从名字空间规则,这些名字通常由大写字母组成,以标明其特性。在编程中不需关心其细节,只要知道,不能对这些名字使用作用域操作符,且这些名字总是全局的即可。在语言手册中查找名字时,这些特殊的名字就被称为宏(macro)。
C++允许一个库的实现在继承C标准库方面存在灵活性。需要注意的是,所有在名字空间std中定义的C名字,同时也保留在了全局名字空间中。例如,std::size_t是一个用来表示尺寸或索引整数类型的typedef,因为size_t来源于C标准库,则名字::size_t也会保留。不是所有的实现都会定义::size_t,但如果某个库定义了它,那么该定义必与std::size_t等价。
【VeryNotice】不需要纠结于哪些名字由C标准库来、哪些是C++专有的,而全部认为是在标准库中即可。当然如果是因为需要而使用同一个名字时,确保该名字在自己的名字空间中就Ok!
使用名字空间
- 使用完全限定名:std::copy(...);
- 使用using指令:using namespace std;
- 使用using声明:using std::copy;
using指令和using声明的区别:前者影响其自身所在的作用域,而后者只影响局部作用域。对于一个名字,一个局部作用域中只能有一个对象或类型使用该名字,using声明会把该名字加入到该局部作用域中而using指令不会。
例1,using指令和using声明的区别
#include <iostream> #include <ostream> void demonstrate_using_directive() { using namespace std; typedef int ostream; ostream x(0); std::cout << x << '\n'; } void demonstrate_using_declaration() { using std::ostream; typedef int ostream; ostream x(0); std::cout << x << '\n'; } int main() {}
类中的using声明
和名字空间的using声明不同,不能随意向任何存在的类引入任何存在的成员,但是可以向某个派生类引入其基类的某个名字。在几种场合中需要使用它,其中常见的两种如下。
- 基类声明了某个函数,其派生类也声明了一个同名函数,且希望能通过重载找到这两个函数。编译器只会在单一的类作用域中寻找重载,使用using声明将基类函数引入派生类作用域,则在处理重载时,就会在派生类作用域中查找这两个函数,并选出最佳匹配项。
- 在私有继承时,通过在派生类的公有段使用using声明,可以有选择地公开一些成员。
无名名字空间
在名字空间定义中的名字可以省略。对于在有名的普通名字空间中的各个名字,它们会被整个程序的所有文件共享,但无名名字空间中的名字专属于包含该名字空间定义的源文件。
无名名字空间也称为匿名空间。匿名空间中的内容为包含该匿名空间的源文件所私有,这保证这些名字不会和其他源文件中的同名名字冲突。当需要使几个帮助函数或其他实现细节保持私有时,就将它们定义在一个匿名空间中。
名字查找
ADL(Argument Dependent Lookup,依赖于实参的名字查找),即编译器检查操作数的类型,并在包含这些类型的名字空间中查找重载的操作符,也称为“Koenig Lookup”。