类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。
数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数以及定义类所需的各种私有函数。 封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类做了什么,而无须了解类的工作细节。
构造函数的名字和类名相同。和其他函数不同的是,构造函数没有返回类型。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
当类没有显示的定义构造函数时,编译器会隐式的定义一个默认构造函数,它又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,必须定义它自己的默认构造函数,原因主要包括:
常见构造函数举例
struct Sales_data {
// C++11新标准,如果我们需要默认的行为,可以在参数列表后面加上=default来要求编译器生成构造函数
Sales_data() = default;
// 冒号与大括号之间的部分称为构造函数初始值列表
Sales_data(const std::string& s): bookNo(s) { }
Sales_data(const std::string& s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(std::istream&);
// 默认初始化为空串
std::string bookNo;
// 类内初始化
unsigned units_sold = 0;
double revenue = 0.0;
};
Sales_data::Sales_data(std::istream& is) {
read(is, *this);
}
通常情况下,构造函数使用类内初始化不失为一种号的选择,因为只要这样的初始值存在,我们就能确保为成员赋予一个正确的值。不过,如果编译器不支持类内初始值,则所有构造函数都应该显示的初始化每一个内置类型的成员。
构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化在赋值。
除了效率问题外更重要的是,一些数据成员必须被初始化(如const、引用或某种未提供默认构造函数的类类型),此时我们必须通过构造函数初始值列表为这些成员提供初始值。建议养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误。
Sales_data::Sales_data(const string& s, unsigned cnt, double price):
bookNo(s), units_sold(cnt), revenue(cnt * price) {} // 列表值初始化
// 没有使用构造函数初始值,这些成员将在构造函数体之前执行默认初始化,然后在进行赋值操作
Sales_data::Sales_data(const string& s, unsigned cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
};
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int& ri;
};
// 错误:ci和ri必须被初始化
ConstRef::ConstRef(int ii) {
i = ii; // 正确
ci = ii; // 错误:不能给const赋值
ri = i; // 错误:ri没被初始化
}
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员的初始化顺序与它们在类定义中出现顺序一致:第一个成员先被初始化,然后第二个,依次类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
一般来讲,初始化的顺序没有什么特别要求,不过如果一个成员是用另一个成员来初始化时,那么这两个成员的初始化顺序就很关键了。
有的编译器具备一项比较友好的功能,即当构造函数初始值列表中的数据成员顺序与这些成员声明的顺序不一致时会产生一条告警信息。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
当对象被默认初始化或值初始化时自动执行默认构造函数
默认初始化的情况包括
值初始化的情况包括
类必须包含一个默认构造函数以便在上述情况下使用。
C++11标准扩展了构造函数初始值的功能,使得可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行自己的初始化过程,或者说它把自己的一些(或全部)职责委托给了其他构造函数。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。
class Sales_data {
// 非委托构造函数使用对应的实参初始化成员
Sales_data(std::string& s, unsigned c, double p): bookNo(s), units_sold(c), revenue(c*p) { }
// 其余构造函数全部委托给另一个构造函数
Sales_data():Sales_data("", 0, 0) {}
Sales_data(std::string s):Sales_data(s, 0, 0) {}
Sales_data(std::istream& is):Sales_data() { read(is, *this); }
};
在C++11新标准中,最好将数据成员的默认值声明成一个类内初始值。
初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。
类内初始值必须使用=
的初始形式或者花括号括起来的直接初始化形式。
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default;
Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht*wd, c) {}
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
class Window_mgr {
private:
// 对screens进行列表初始化
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
有时会出现这样一种情况,我们希望能改变类的某个数据成员,即使是在一个const成员函数内。可以通过mutable关键字来实现。
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; //即使在一个const对象内也能被修改
};
void Screen::some_member() const {
++access_ctr;
}
尽管some_member是一个const成员函数,它仍能改变access_ctr的值。该成员是个可变成员。
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义既可以在类内部也可以在类的外部。作为接口组成部分的非成员函数,他们的定义和声明都在类的外部。
定义在类内部的函数是隐式的inline函数。
struct Sales_data {
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
};
//Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::instream &read(std::instream&, Sales_data&);
上面的例子中成员函数isbn定义在了类内,而成员函数combine和avg_price函数定义在类外。
在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员函数被声明为常量成员函数,那么它的定义也必须在参数列表后面明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名。
double Sales_data::avg_price() const {
if(units_sold)
return revenue/units_sold;
else
return 0;
}
当avg_price使用revenue和units_sold时,实际上它隐式的使用了Sales_data的成员。
Sales_data total;
total.isbn();
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,会用请求该函数的对象地址初始化this。例如上面代码中的total.isbn(),编译器负责把total的地址传递给isbn的隐式形参this,可以等价的认为编译器将该调用重写成了如下的形式:
//伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的就是这个对象。任何对类成员的直接访问都被看作this的隐式引用,也就是说,当isbn函数中使用bookNo时,它隐式的使用this指向的成员,就像我们书写了this->bookNo一样。
对我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或者变量的行为都是非法的。我们可以在成员函数体内部使用this。因为尽管没有必要,但是我们还是能把isbn函数定义为如下形式:
std::string isbn() const { return this->bookNo; }
因为this的目的总是指向“这个”对象,所以this是一个常量指针,不允许改变this中保存的地址。
编译器首先编译成员的声明,然后才轮到成员函数体(如果右的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
isbn函数中紧随参数列表之后的const关键字的作用是修改隐式this指针的类型。
默认情况下,this的类型是指向类类型非常量对象的常量指针,即 class_type* const this。这就意味着(在默认情况下)我们不能把this绑定到一个常量对象上,也就表示不能在常量对象上调用普通的成员函数。
如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成const class_type* const this。毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设置成指向常量的指针有助于提高函数的灵活性。
然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就称为一个问题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称为常量成员函数(const member function)。
//伪代码,说明隐式this指针是如何使用的
//下面的代码是非法的:因为我们不能显示地定义自己的this指针
//谨记此处的this是一个指向常量的指针,isbn是一个常量成员
std::string Sales_data::isbn(const Sales_data *const this)
{ return this->isbn; }
因为this是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,isbn可以读取调用它的对象的数据成员,但是不能写入新值。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
定义在类内部的成员函数是自动inline的。
对于定义在类外部的成员函数,可以在类的内部把inline作为声明的一部分显示的声明成员函数,同样的,也在类的外部用inline关键字修饰函数的定义。
虽然无须在定义和声明的地方同时说明inline,但是这么做是合法的。通常情况只需要在类外部定义的地方说明inline,这样可以使类更容易理解。
和我们在头文件中定义inline函数一样,inline成员函数也应该与相应的类定义在同一个头文件中。
和普通函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。成员函数的函数匹配过程同样与非成员函数类似。
基于const的重载
class Screen {
public:
using pos = std::string::size_type;
Screen& set(char);
Screen& set(pos, pos, char);
const Screen& display(std::ostream&) const;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
从逻辑上来说,通过display显示一个Screen并不需要改变它的内容,因此我们将display定义为一个const成员,此时,this指针将是一个指向const的指针而*this是一个const对象。但是此时,我们不能把display嵌入到一组动作的序列中去:
Screen myScreen;
// 如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*');
解决方法如下:
class Screen {
public:
Screen& display(std::ostream& os) {
do_display(os); return *this;
}
const Screen& display(std::ostream& os) const {
do_display(os); return *this;
}
private:
void do_display(std::ostream& os) const { os << contents; }
};
当一个成员调用另外一个成员时,this指针在其中隐式地传递。当do_display完成后,display函数各自返回解引用this所得的对象。在非常量版本中,this指向一个非常量对象,因此display返回一个普通的引用;而const成员则返回一个常量引用。
当我们在某个对象上调用display时,该对象是否是const决定了应该调用display的哪个版本
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); //调用非常量版本
blank.display(cout); //调用常量版本
函数combine的设计初衷类似于复合赋值运算符+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算符则通过显示的实参被传入函数。
Sales_data& Sales_data::combine(const Sales_data& rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。
total.combine(trans);
return 语句解引用this指针以获得执行该函数的对象,换句话说,上面的这个调用返回total的引用。
实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推。尽管实参与形参存在对应关系,但是并没有规定实参的求值顺序。编译器能以任何可行的顺序对实参求值。
形参的类型决定了形参和实参交互的方式。如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参
当形参是引用类型时,我们说它对应的实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名。
使用引用能避免拷贝,拷贝大的类类型或者容器对象比较低效,甚至有的类类型根本不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
如果函数无须改变引用形参的值,最好将其声明为常量引用。
把函数不会改变的形参定义成普通的引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用普通引用而非常量引用也会极大的限制函数所能接受的实参类型(不能把
const
对象,字面值或者需要类型转换的对象传递给普通的引用形参)。
一个函数只能返回一个值,然而有时候函数需要同时返回多个值,引用形参为我们返回多个结果提供了有效的途径。
当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by vaule)
熟悉C语言的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参代替指针。
实参为数组时,数组是以指针的形式传递给函数的,所以函数并不知道数组的具体大小,因此调用者需要提供一些额外信息,保证访问数组时不会越界
使用标记指定数组长度,这种情况通常用于字符串,因为字符串的最后一个为空字符
void print(const char* cp)
{
if(cp)
while(*cp)
cout << *cp++;
}
显示传递一个表示数组大小的形参
void print(const int ia[], size_t size) {
for(size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}
使用标准库规范
//begin指向要输出的首元素,end指向尾元素的下一位置
void print(const int* begin, const int* end) {
while(begin != end)
cout << *begin++ << endl;
}
指针的引用形参举例
void swap(int* &a, int* &b)
{
int * temp = a;
a = b;
b = temp;
return;
}
int main()
{
int a = 10, b = 20;
int* pa = &a, *pb = &b;
cout << "a: " << pa << endl;
cout << "b: " << pb << endl;
//不能使用swap(&a, &b),编译会出错
//提示invalid initialization of non-const reference of type ‘int*&’ from an rvalue of type ‘int*’
//因为 &a 表达式返回的是个右值,而引用需要的是左值
swap(pa, pb);
cout << "a: " << pa << endl;
cout << "b: " << pb << endl;
return 0;
}
含有可变形参的函数
C++11新标准提供了两种主要的方法
如果所有的实参类型相同,可以传递一个名为initializer_list
的标准库类型
initializer_list
是一种标准库类型,和vector
一样,也是一种模板类型,用于表示某种特定类型的值的数组,定义initializer_list
对象时,必须说明列表中所含元素的类型。initializer_list
类型定义在同名的头文件中。
#include <initializer_list>
using std::initializer_list
vector
不一样的是,initializer_list
对象中的元素永远是常量值,不能改变initializer_list
对象中元素的值。initialier_list
提供的操作
initializer_list<T> lst; //默认初始化; T类型元素的空列表
// lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const
initializer_list<T> lst{a, b, c...};
// 拷贝或赋值一个initializer_list对象不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素
lst2(lst);
lst2 = lst;
lst.size(); //列表中元素数量
lst.begin(); //返回指向lst中首元素的指针
lst.end(); //返回指向lst中尾元素下一位置的指针
如果实参类型不同,可以编写一种特殊的函数,就是所谓的可变参数模板
C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。这种功能一般只用于与C函数交互的接口程序。
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs
的C标准库功能。通常,省略符形参不应用于其他目的,省略符形参应该仅仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置。
void foo(parm_list, ...);
void foo(...);
不要返回局部对象的引用或指针。
函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。同样,一旦函数完成,局部对象释放,局部对象的指针也将指向一个不存在的对象。
引用返回左值
函数的返回类型决定函数调用是否是左值。
调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别的,我们能为返回类型是非常量引用的函数的结果赋值。
char& get_val(string &str, string::size_type ix)
{
return str[ix];
}
int main()
{
string s("a value");
cout << s << endl;
get_val(s, 0) = 'A'; //将s[0]的值改为A
cout << s << endl; //输出 A value
return 0;
}
返回指向数组的指针
使用类型别名的方式
typedef int arrT[10]; //arrT是一个类型别名,它表示的类型是含有10个整数的数组
using arrT = int[10]; //arrT的等价声明
arrT* func(int i); //func返回一个指向10个整数的数组的指针
Type (*function(parameter_list))[dimension]
Type表示元素的类型,dimension表示数组的大小,(*function(parameter_list))两端的括号一定要有,如果没有括号,函数的返回类型将是指针的数组。
int (*func(int i))[10];
func(int i)表示调用func函数时需要一个int类型的实参
(*func(int i))意味着我们可以对函数调用的结果执行解引用操作
(*func(int i))[10]表示解引用func的调用将得到一个大小是10的数组
int (*func(int i))[10]表示数组中的元素是int类型
使用尾置返回类型
在C++11新标准中还有一种可以简化上述func声明的方法,就是使用尾置返回类型(tailing return type)。
任何函数定义都可以使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表后面,我们在本应该出现返回类型的地方放置一个auto:
//func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i) -> int(*)[10];
使用decltype
还有一种情况,如果我们知道函数返回的指针指向哪个数组,就可以使用decltype关键字声明返回类型。
int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};
//返回一个指针,该指针指向含有5个整数的数组
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? &odd : & even;
}
decltype并不负责把数组类型转换成对应的指针,所以decltype的结果是个数组,要想表示arrPtr返回指针还必须在函数声明时加一个*符号。
如果同一个作用域内的几个函数名称相同但是形参列表不同,我们称之为重载(overloaded)函数。 main函数不能重载。
定义重载函数
对于重载函数来说,它们应该在形参数量或者形参类型上有所不同。不允许两个函数除了返回类型以外其他所有的要素都相同。
Record lookup(const Account&);
bool lookup(const Account&); //错误: 与上一个函数相比只有返回类型不同
重载和const形参
顶层const不影响传入函数的对象,一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来
Record lookup(Phone);
Record lookup(const Phone); //重复声明了Record lookup(Phone)
Record lookup(Phone*);
Record lookup(Phone* const); //重复声明了Record lookup(Phone*)
如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象(底层const)可以实现函数重载。
//下面定义了4个独立的重载函数
Record lookup(Account&);
Record lookup(const Account&);
Record lookup(Account*);
Record lookup(const Account*);
调用重载的函数
函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫重载确定(overloaded resolution)。
当调用重载函数时有三种可能的结果:
默认实参作为形参的初始值出现再形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
函数调用时实参按其位置接卸,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。当设计含有默认实参的函数是,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时。
typedef string::size_type sz;
string screen(sz ht = 24, sz wid=80, char backgrnd = ' ');
string window;
window = screen(); //等价于screen(24, 80, ' ');
window = screen(66); //等价于screen(66, 80, ' ');
window = screen(66, 256); //等价于screen(66, 256, ' ');
window = screen(66, 256, '#');
window = screen(, , '?'); //错误: 只能省略尾部的实参
内联函数
内联函数通常就是将它在每个调用点上“内联地”展开。
一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。在函数的返回类型前面加上关键字inline,就可以将函数声明成内联函数了。
inline const string& shorterString(const string &s1, const string &s2)
{
return s1.size() <= s2.size() ? s1 : s2;
}
constexpr函数
constexpr函数是指能用于常量表达式的函数。constexpr函数的定义需要遵循下面几项约定:
constexpr函数不一定返回常量表达式
constexpr函数举例
constexpr int new_sz() { return 32; }
constexpr int foo = new_sz(); //foo是一个常量表达式
编译器能在程序编译时验证new_sz函数返回的是常量表达式,所以可以用new_sz函数初始化constexpr类型的变量foo。
执行初始化任务时,编译器把对constexpr函数的调用替换成其结果。为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数。
通常将内联函数和constexpr函数定义在头文件中。
程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用,当C++应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能: assert 和 NDEBUG。
assert预处理宏
assert是一种预处理宏。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:
assert(expr);
首先对expr求值,如果表达式为假(即0),assert输出信息并终止程序运行。如果表达式为真(即非0),assert什么也不做。
assert宏定义在cassert头文件中。
预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明。
assert宏常用于检查“不能发生”的条件。
NDEBUG预处理变量
assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
可以使用一个#define 语句定义NDEBUG,从而关闭调试状态。 很多编译器也提供了一个命令行选项使我们可以定义预处理变量:
$CC -D NDEBUG main.cc
这条命令等价于在main.cc文件的一开始写#define NDEBUG
assert应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。
除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef 和#endif之间的代码;如果定义了NDEUBG,这些代码将被忽略掉:
void print(const int ia[], size_t size)
{
#ifndef NDEBUG
//__func__ 是编译器定义的一个局部静态变量,用于存放函数的名字
cerr << __func__ << ": array size is " << size << endl;
#endif
...
}
__func__ 存放函数名的字符串字面值 __FILE__ 存放文件名的字符串字面值 __LINE__ 存放当前行号的整型字面值 __TIME__ 存放文件编译时间的字符串字面值 __DATE__ 存放文件编译日期的字符串字面值
调用重载函数时应尽量避免强制类型转换,如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级
精确匹配,包括一下情况
通过const转换实现的匹配
通过类型提升实现的匹配
通过算术类型转换或指针转换实现的匹配
通过类类型转换实现的匹配
C++标准 try、catch、throw是C++标准里的语法,标准只要求try catch捕获throw出来的异常,并不要求捕获系统异常(被0除,段错误,CPU异常等)。从C++层面来说,不要期望try,catch能捕获系统异常。 GCC所实现的C++异常处理框架中,它的catch(…)语法,并不能捕获系统异常。因此,这给C++中异常处理的良好运行打了大大的折扣。
C++标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以用在用户编写的程序中使用,它们分别定义在4个头文件中:
exception
。它只报告异常的发生,不提供任何额外信息。stdexcept 头文件定义了几种常用的异常类
异常类 | 描述 |
exception | 最常见的问题 |
runtime_error | 只有运行时才能检测出的错误 |
range_error | 运行时错误:生成的结果超出了有意义的值域范围 |
overflow_error | 运行时错误:计算上溢 |
underflow_error | 运行时错误:计算下溢 |
logic_error | 程序逻辑错误 |
domain_error | 逻辑错误:参数对应的结果值不存在 |
invalid_argument | 逻辑错误:无效参数 |
length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 |
out_of_range | 逻辑错误:使用一个超出有效范围的值 |
标准库异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值。
exception
、bad_alloc
和bad_cast
对象,不允许为这些对象提供初始值。string
对象或C风格字符串初始化,不允许使用默认初始化方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。异常类型只定义了一个名为
what
的成员函数,该函数没有参数,返回值是一个指向C风格字符串的const char*
。该字符串的目的是提供关于异常的一些文本信息。what
函数返回的具体内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则what
返回该字符串。对于其他无初始值的异常类型来说,what
返回的内容由编译器决定。
运算符的优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值。对于没有指定执行顺序的运算符来说,如果表达式指向并修改了同一个对象,将会引发错误并产生未定义的行为。
int i = 0;
cout << i << " " << ++i << endl; //未定义的
因为程序是未定义的,所以我们无法推断它的行为。编译器可能先求++i的值再求i的值,此时输出结果为1 1;也可能先求i的值再求++i的值,输出结果为0 1;甚至编译器还可能做完全不同的操作。
有4种运算符明确规定了运算对象的求值顺序。
&&
运算符,它规定先求左侧运算对象的值,只有当左侧运算对象的值为真时才继续求右侧运算对象的值。||
运算符,当且仅当左侧运算对象为假时才对右侧运算对象求值。?:
运算符,
运算符运算对象的求值顺序与优先级和结合律无关,在一条形如f() + g() * h() + j()的表达式中:
如果f、g、h和j是无关函数,它们既不会改变同一个对象的状态也不执行IO任务,那么函数的调用顺序不受限制。反之,如果其中某几个函数影响同一个对象,则它是一条错误的表达式,将产生未定义的行为。
整数相除结果还是整数,也就是说,如果商含小数部分,直接丢弃。
当计算的结果超出该类型所能表示的范围时就会 溢出
short short_value = 32767; //如果short类型占16位,则能表示的最大值是32767
short_value += 1; //该计算导致溢出
cout << "short value: " << short_value << endl; // -32768
很多系统在编译和运行时都不报溢出错误,像其他未定义的行为一样,溢出的结果是不可预知的。通常情况下该值会发生 环绕(wrapped around)。
除法运算中,C++11新标准规定商一律向0取整(即直接切除小数部分)。
如果m%n不等于0,则它的符号和m相同。 (-m)/n = m/(-n) = -(m/n) (-m)%n = -(m%n) m%(-n) = m%n
21 % 6; // 3
21 / 6; // 3
21 % 7; // 0
21 / 7; // 3
-21 % -8; // -5
-21 / -8; // 2
21 % -5; // 1
21 / -5; // -4
递增和递减运算符有两种形式:前置版本和后置版本。
除非必要,否则不用递增递减运算符的后置版本。前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了运算符对象。与之相比,后置版本需要将原始值存储下来以便于返回这个为修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
位运算符的运算对象可以是带符号的,也可以是无符号的。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖与机器。而且,此时的左移操作可能会改变符号位的值,因此是一种未定义的行为。
关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
二进制位或者向左移动或者向右移动,移出边界之外的位就被舍弃掉了。
<<
在右侧插入值为0的二进制位右移运算符>>
的行为则依赖与其左侧运算对象的类型
结合律 | 运算符 | 功能 | 用法 |
---|---|---|---|
左 | :: | 全局作用域 | ::name |
左 | :: | 类作用域 | class::name |
左 | :: | 命名空间作用域 | namespace::name |
左 | . | 成员选择 | object.member |
左 | -> | 成员选择 | pointer->member |
左 | [] | 下标 | expr[expr] |
左 | () | 函数调用 | func(expr_list) |
左 | () | 类型构造 | type(expr_list) |
左 | ++ | 后置递增运算 | lvalue++ |
左 | – | 后置递减运算 | lvalue– |
右 | typeid | 类型ID | typeid(type) |
右 | typeid | 运行时类型ID | typeid(expr) |
右 | explicit cast | 类型转换 | cast_name |
右 | ++ | 前置递增运算 | ++lvalue |
右 | – | 前置递减运算 | –lvalue |
右 | ~ | 位求反 | ~expr |
右 | ! | 逻辑非 | !expr |
右 | - | 一元负号 | -expr |
右 | + | 一元正号 | +expr |
右 | * | 解引用 | *expr |
右 | & | 取地址 | &expr |
右 | () | 类型转换 | (type)expr |
右 | sizeof | 对象的大小 | sizeof expr |
右 | sizeof | 类型的大小 | sizeof(type) |
右 | sizeof | 参数包的大小 | sizeof…(name) |
右 | new | 创建对象 | new type |
右 | new[] | 创建数组 | new type[size] |
右 | delete | 删除对象 | delete expr |
右 | delete[] | 删除数组 | delete[] expr |
右 | noexcept | 能否抛出异常 | noexcept(expr) |
左 | ->* | 指向成员选择的指针 | ptr->*ptr_to_member |
左 | .* | 指向成员选择的指针 | obj.*ptr_to_member |
左 | * | 乘法 | expr * expr |
左 | / | 除法 | expr / expr |
左 | % | 取模(取余数) | expr % expr |
左 | + | 加法 | expr + expr |
左 | - | 减法 | expr - expr |
左 | « | 向左移位 | expr « expr |
左 | » | 向右移位 | expr » expr |
左 | < | 小于 | expr < expr |
左 | <= | 小于等于 | expr <= expr |
左 | > | 大于 | expr > expr |
左 | >= | 大于等于 | expr >= expr |
左 | == | 相等 | expr == expr |
左 | != | 不相等 | expr != expr |
左 | & | 位与 | expr & expr |
左 | ^ | 位异或 | expr ^ expr |
左 | | | 位或 | expr | expr |
左 | && | 逻辑与 | expr && expr |
左 | || | 逻辑或 | expr || expr |
右 | ?: | 条件 | expr ? expr : expr |
右 | =, *=, /=, %=, +=, -=, «=, »=, &=, |=, ^= | 赋值与复合赋值 | lvalue = expr等 |
右 | throw | 抛出异常 | throw expr |
左 | , | 逗号 | expr, expr |
C++标准库一方面对库类型所提供的操作做了详细规定,另一方面也对库的实现者做出了一些性能上的需求。因此,标准库类型对于一般应用场合来说由足够的效率。
在函数中直接使用namespace::name
使用using namespace::name
声明
位于头文件中的代码一般不应该使用using声明
每个名字都要需要独立的using声明
#include <iostream>
using std::cin;
using std::endl;
int main()
{
int i;
cin >> i;
cout << i; // 错误:没有对应的using声明,必须使用完整的名字 std::cout
std::cout << i << ednl;
return 0;
}
string
标准库类型
string
表示可变长的字符序列,使用string
类型前必须首先包含头文件#include <string>
using std::string
定义和初始化string
对象
string s1;
string s2(s1);
string s2 = s1;
string s3("value");
string s3 = "value";
string s4(10, 'c');
string s4 = string(10, 'c');
如果使用等号=
初始化一个变量,实际上执行的是 拷贝初始化(copy initialization) 编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是 直接初始化(direct initialization)。
string
对象上的操作
os<<s //将s写到输出流os当中,返回os
//从is中读取字符串赋给s,字符串以空白分割,返回is
//在执行读取操作时,string对象会自动忽略开头的空白并从第一个真正的字符开始读起,直到遇见下一处空白为止
is>>s
//从is中读取一行赋给s,返回is
//函数从is中读取数据,直到遇到换行符为止,s中不包含换行符,如果第一行是空行,则s为空字符串
getline(is, s)
s.empty() //s为空返回true,否则返回false
s.size() //返回s中字符的个数,返回类型为string::size_type
s[n] //返回s中的第n个字符的引用,位置n从0计数
s1+s2 //返回s1和s2连接后的结果
s1=s2 //用s2的副本代替s1中原来的字符
s1==s2 //如果s1和s2中所包含的字符完全一样,则返回true,大小写敏感
s1!=s2
<, <=, >, >= //利用字符在字典中的顺序进行比较且对大小写敏感
string
类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体现了标准库类型与机器无关的特性,类型size_type
即是其中的一种。size_type
是一个无符号整型数,因此切记不要用string.size()
的返回值跟有符号的整型数做比较。
string
对象的下标运算符可以用于访问已经存在的元素,而不能用于添加元素。
当把string
对象和字符字面值以及字符串字面值混在一条语句中使用时,必须保证每个加法运算符的两侧运算对象中至少由一个是string
对象。
string s1 = "hello";
string s2 = s1 + ", "; // 正确,把一个string对象和一个字面值相加
string s3 = "hello" + ", "; // 错误,两个运算对象都不是string
string s4 = s1 + ", " + "world"; // 正确,s1 + ", "的返回值是个string对象
string s5 = "hello" + ", " + s1; // 错误,"hello" 和 ", " 是两个字面值
由于某些历史原因,也为了与C兼容,所以C++中的字符串字面值并不是标准库类型string
的对象。切记,字符串字面值与string是不同的类型
处理string
对象中的字符
cctype头文件中的函数
isalnum(c) //当c是字母或数字时为true
isalpha(c) //当c是字母时为true
iscntrl(c) //当c是控制字符时为true
isdigit(c) //当c是数字时为true
isgraph(c) //当c不是空格但可打印时为真
islower(c)
isprint(c) //当c是可打印字符时为真(即c是空格或c具有可视形式)
ispunct(c) //当c是标点符号时为真
isspace(c) //当c是空白时为真(空格/横向制表符/纵向制表符/回车符/换行/走纸)
isupper(c)
isxdigit(c) //当c是十六进制数时为真
tolower(c)
toupper(c)
C++标准库中兼容了C语言的标准库。C语言的头文件形如name.h,C++则将这些文件命名为cname。 在名为cname的头文件中定义的名字从属于命名空间std,而定义在名为.h的头文件中的则不然。 一般来讲,C++程序应该使用名为cnmae的头文件而不是使用name.h的形式。
#include <iostream>
#include <string>
using std::string;
using std::cout; using std::cin; using std::endl;
int main()
{
string s("Hello world!!!");
for(auto &c : s)
c = toupper(c);
cout << s << endl;
return 0;
}
C++标准并不要求标准库检测下标是否合法,一旦使用了一个超出范围的下标,就会产生不可预知的结果。
vector
标准库类型
vector
表示对象的集合,其中所有对象的类型都相同。集合中的每个对象都有一个与之对应的索引,索引用于访问对象。 因为vector
容纳着其他对象,所以它也常被称为容器(container)。使用vector
时必须包含头文件#include <vector>
using std::vector;
定义和初始化vector
vector<T> v1; //v1是一个空vector
vector<T> v2(v1);
vector<T> v2 = v1;
vector<T> v3(n, val);
vector<T> v4(n); //v4 包含了n个重复地执行了值初始化的对象
vector<T> v5{a, b, c ...};
vector<T> v6 = {a, b, c ...};
通常情况下,可以只提供vector
对象容纳的元素数量而略去初始值(例如v4),此时库会创建一个值初始化的元素初值,并把它赋给容器中的所有元素。这个初值由vector
对象中元素的类型决定。
如果vector
对象的元素是内置类型,则元素初始值自动设为0。如果元素是某种类类型,则元素由类默认初始化,所以要求该类类型必须支持默认初始化。
初始化过程会尽可能地把花括号里面的值当做是元素初始值的列表来处理,只有在无法进行列表初始化时才会考虑其他初始化方式,如默认值初始化。
vector<int> v1(10); //v1有10个元素,每个值都是0
vector<int> v2{10}; //v2有1个元素,该元素的值是10
vector<int> v3(10, 1); //v3有10个元素,每个值都是1
vector<int> v4{10, 1}; //v4有2个元素,分别是10 和 1
vector<string> v5{"hi"}; //列表初始化,v5有一个元素
vector<string> v6("hi"); //错误:不能使用字符串字面值构建vector对象
vector<string> v7{10}; //v7有10个默认初始化的元素
vector<string> v8{10, "hi"}; //v8有10个值为"hi"的元素
vector
对象上的操作
v.empty() //如果v为空则返回true,否则返回false
v.size() //返回值类型为vector<T>::size_type
v.push_back(t) //向v的尾端添加一个值为t的值
v[n] //返回v中第n个位置上元素的引用
v1 = v2
v1 = {a, b, c...}
v1 == v2 //v1和v2相等当且仅当元素数量相同且对应位置的元素值都相同
v1 != v2
<, <=, >, >= //以字典顺序进行比较
C++标准要求vector
应该能在运行时高效快速的添加元素。因此在定义vector
对象的时候设定其大小就没有必要了,事实上如果这么做性能可能更差。
通常都是先定义一个空的vector
对象,然后在运行时向其中添加具体值。
vector
对象的下标运算符可以用于访问已经存在的元素,而不能用于添加元素。
vector
使用限制
for
循环中向vector
对象添加元素vector
对象容量的操作(如push_back
),都会使该vector
对象的迭代器失效除了
vector
之外,标准库还定义了其他几种容器。所有标准库容器都可以使用迭代器(iterator),但是只有其中少数几种才同时支持下标运算符。string
类型不属于容器,但是它支持很多与容器类似的操作,string
类型也支持迭代器。 类似于指针类型,迭代器也提供了对对象的间接访问。就迭代器而言,其对象为容器中的元素或者string
对象中的字符。
迭代器类型
拥有迭代器的标准库类型使用iterator
和const_iterator
来表示迭代器的类型
vector<int>::iterator it; //it能读写vector<int>的元素
string::iterator it2; //it2能读写string对象中的字符
vector<int>::const_iterator it3; //it3只能读元素,不能写元素
string::const_iterator it4; //it4只能读字符,不能写字符
const_iterator
和常量指针类似,能读取但是不能修改它所指向的元素值。相反,iterator
的对象可读可写。如果vector
对象或string
对象是一个常量,只能使用const_iterator
;如果vector
对象或string
对象不是常量,那么既能使用iterator
也能使用const_iterator
。
获取迭代器
拥有迭代器的类型都有返回迭代器的成员函数。
auto b = v.begin();
auto e = v.end();
begin方法返回指向第一个元素的迭代器,end方法返回指向容器“尾元素的下一位置”的迭代器,也就是说,该迭代器指示的是容器的一个本不存在的“尾后(off the end)”元素。
如果容器为空,则begin和end返回的是同一个迭代器,都是尾后迭代器。
begin和end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator
;如果对象不是常量,返回iterator
。
vector<int> v;
const vector<int> cv;
auto it1 = v.begin(); //it1的类型是vector<int>::iterator
auto it2 = cv.beging(); //it2的类型是vector<int>::const_iterator
C++11标准引入两个新函数,分别是cbegin
和cend
,无论vector
对象本身是否是常量,返回值都是const_iterator
。
标准容器迭代器的运算符
*iter //返回迭代器iter所指元素的引用
iter->mem //解引用iter并获取该元素的名为mem的成员,等价与(*iter).mem
++iter
--iter
iter1 == iter2 //两个迭代器指向的元素相同或者都是同一个容器的尾后迭代器,则返回true
iter1 != iter2
因为end操作返回的迭代器并不实际指向某个元素,所以不能对其进行递增或解引用操作。
迭代器运算
iter + n
iter - n
iter += n
iter -= n
iter1 - iter2 //参与运算的两个迭代器必须属于同一个容器
>, >=, <, <= //参与运算的两个迭代器必须属于同一个容器
迭代器距离指的是右侧迭代器向前移动多少位置就能追上左侧迭代器,其类型为difference_type
的带符号整型。string
和vector
都定义了difference_type
,因为这个距离可正可负,所以difference_type
是带符号类型的。