0%

KBC Poker 2

用倦Thindpad T420自带的键盘,也想尝试一下机械键盘的手感如何,就入手了一个机械键盘,KBC Poker 2。

入手Poker 2的原因不仅仅是Filco Minila Air和HHKB的价格有点高,还有一点吸引我的原因就是Poker 2支持全键位硬件可编程。

Poker 2已经是第二代了,相比起第一代,带来了许多的改进,网上的总结有这么几点:

  • 保留了经典的US配列
  • 增加了数控钢板,使得茶轴、青轴和红轴的手感大大增强,黑轴基本不变
  • 内置7级亮度调节的DIY灯控支持,插上LED就会亮
  • 全新加厚PBT键帽,带来目前最为细腻的PBT触感
  • 增加了实用和进阶的附件六颗RGB加厚PBT套装和钢丝拔键器
  • 换代的卫星轴进一步提升了手感,彻底让卫星轴翻身做主人
  • 仍然保持一代的价格,加量完全不加价,499元
  • USB任意6键无冲
  • 自带延时编程的全键位硬件可编程

键盘从淘宝上购买,只是中通快递,等得我整个人都不好了。。。

除了键盘外,还随带附送的配件:分离式可拆卸的USB数据线,一个钢丝拔键器,一套RGB的PBT材质键帽。换下原来的键帽,把六个RGB键帽装上以后,就是这样了。

因为是60%尺寸的设计,省去了方向键和其他的一些功能键,改用Fn的组合键和Pn的编程键实现相应的功能,因此键盘更加玲珑小巧、方便携带。在文章的后面会给出自己修改键位的办法。

键帽由于是PBT材质的,而且还是加厚的键帽,手感很不错,空格键上的”Enjoy your feeling”正是说明了这一点。

但是细节处的做工就不是那么精细了,键帽边缘有少许的毛刺。但我不是强迫症,平时使用根本看不见,So it doesn’t matter.

这次入手的Poker 2是茶轴的,网上茶轴的介绍如下:

全面兼顾:茶轴

茶轴的手感比较均衡,也可说是较为中庸,带有段落感,触发键程为2.0mm。另外,其压力克数比较小,只有60g,敲击显得非常轻松,能兼顾打字和游戏使用。

敲击茶轴的感觉如同小范围内的极速轻金属碰撞,很神秘的那么一下,结合了青轴和黑轴的特点,或者看成它是最没有特点的一种特殊轴,这也是手感最类似于传统键盘的机械键盘,压力在黑轴和青轴中间。

它的段落感,你按下一个键后段落感可以给你确认感,同时压力不太大,适合那种瞬间飚快捷键的快手。

由于没有使用过其他机械键盘,我只能和T420的键盘做对比。使用下来的感觉是,和T420的键盘相比,不需要多大的力量就可以触发,按下去的时候有段落感,触发段落感的力量也不大。用了Poker 2以后,感觉就是“回不去了。。。”。

USB数据线的接口,看着像micro USB的,但其实不是,有点像以前老式的好记星的接口。

背面有四个橡胶脚垫,不支持角度的调节,不过键盘正面的有弧度的设计,比较符合人体工程学,因此还是很舒适的。

这里就是DIP开关。最早见到这个DIP开关是在HHKB上,Poker 2的DIP开关与HHKB的还是有所区别的。记得当初关注HHKB并不是因为机械键盘的缘故,而是Caps和Control键可以互换,加上当时在Ubuntu下开发,所以一直眼馋HHKB,只是价格的原因,一直没入手。

Poker 2总结

Poker 2使用了小尺寸的设计,精简了多余的按键,加上分离式可拆卸的USB,大大提升了便携性。总体来说,做工中规中矩,PBT加厚键帽,内置钢板,但不足的是小细节的地方,比如键帽的边缘。

键位修改

首先,用键盘编程在V键上,把Win键编程为:Pn+V。因为后面需要用开关把做Win改为左Fn,还有就是目前是在Windows下工作,Win键不可少,所以这里要提前设置Win的替代键位。

接下来,用键盘编程在L上,把Win+L键编程为:Pn+L;在,键上,把Win+Space编程为:Pn+,。其实这里不是必须的,我只是为了方便。

然后就是设置键盘后面的几个开关,背面的4个DIP开关的功能分别是:

  • 开关1:Caps与左Win切换
  • 开关2:右Ctrl与`~切换
  • 开关3:左Win与Fn切换
  • 开关4:键盘写保护,键位编程

将开关1和3都拨到ON的位置,效果:Caps变成了Fn,左Win变成了Caps。

到这里键位修改就完成了。

基类中类型相关的函数与派生类不做改变直接继承的函数是不同的,当希望派生类定义合适自身的版本,此时基类就将这些函数声明为虚函数。

基类

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Quote {
public:
Quote() = default;
Quote(const std::string &book, double sales_price)
: bookNo(book), price(sales_price) {}
std::string isbn() const { return bookNo; }
virtual double net_price(std::size_t n) const { return n * price; }
virtual ~Quote() = default;

private:
std::string bookNo;

protected:
double price = 0.0;
};

成员函数与继承

基类有两种成员函数,

  1. 希望派生类进行覆盖的
    • 当使用指针或引用调用虚函数时,该调用将被动态绑定
    • 任何除构造函数之外的非静态函数都可以是虚函数
    • 如果某个函数在基类中被声明为虚函数,那么在派生类中隐式地也是虚函数
  2. 希望派生类直接继承而不需要改变的
    • 如果不是虚函数,则其解析过程发生在编译时而非运行时

访问控制与继承

派生类能访问公有成员和受保护的成员,但不能访问私有成员(继承了,但无权限访问)。

派生类

类派生列表中的访问说明是控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生类是公有的,则基类的公有成员也是派生类接口的组成部分。可以将公有派生类型的对象绑定到基类的引用或指针上。

1
2
3
4
5
6
7
8
9
10
class BulkQuote : public Quote {
public:
BulkQuote() = default;
BulkQuote(const std::string&, double, std::size_t, double);
double net_price(std::size_t) const override;

private:
std::size_t min_qty = 0;
double discount = 0.0;
};

派生类中的虚函数

派生类经常,但不总是,覆盖继承的虚函数。如果没有覆盖,则虚函数行为类似其他的普通成员,直接使用基类中的版本。

派生类必须对需要覆盖的重新声明,派生类可以在这样的函数前加上virtual,但并非必须。C++11允许显式地在声明的最后override注明覆盖。

派生类向基类的类型转换

一个派生类对象中,继承自基类的部分和派生类的部分不一定是连续存储的

由于派生类对象中含有与其基类对应的组成部分,因此能将派生类对象当成基类对象来使用,能将基类指针或引用绑定到派生类对象中基类的部分上。这叫做派生类到基类的类型转换

由于一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类到派生类的自动类型转换,即使一个基类的指针或引用已经绑定在一个派生类对象上。

1
2
3
4
5
Quote item;
BulkQuote bulk;
Quote *p = &item;
p = &bulk;
Quote &r = bulk;

派生类构造函数

每个类控制它自己的成员初始化过程。派生类并不能直接初始化继承自基类的成员。初始化时,先初始化基类的部分,然后按照声明的顺序(与初始化列表顺序无关)依次初始化派生类的成员。

如果不明确指明初始化,派生类的基类部分会像数据成员一样执行默认初始化

1
2
3
BulkQuote::BulkQuote(const std::string& book, double p, std::size_t qty,
double disc)
: Quote(book, p), min_qty(qty), discount(disc) {}

每个类负责定义各自的接口,要想与类对象交互,必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。因此应当调用基类的构造函数初始化从基类继承而来的成员。

派生类的作用域嵌套在基类的作用域之内。

继承与静态成员

如果基类定义了静态成员,则在整个继承体系中只存在该成员的唯一定义,每个静态成员只存在唯一的实例

派生类的声明

派生类的声明不能包含派生列表。

被用作基类的类

某个类要用作基类,必须已经定义,而非仅仅声明。因为派生类中,要使用继承自基类的成员,派生类必须知道他们是什么。

防止继承可以使用final

静态类型和动态类型

变量或表达式的静态类型:编译时总是已知的,变量声明时类型或表达式生成的类型。动态类型:变量或表达式表示的内存中对象的类型,运行时才可知。

基类指针或引用的静态类型可能与其动态类型不一致

1
2
3
4
5
6
double print_total(ostream &os, const Quote &item, size_t n) {
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() << "# sold: " << n << " total due: " << ret
<< endl;
return ret;
}

上面item的静态类型是Quote&,动态类型直到运行时调用函数时才会知道。如果传入的是BulkQuote,则动态类型是BulkQuote。

由于一个基类对象可能是派生类对象的一部分,也可能不是,因此不存在从基类到派生类的自动类型转换,即使一个基类的指针或引用已经绑定在一个派生类对象上。如果需要转换,

  • 如果基类中有一个或多个虚函数,则可以使用dynamic_cast请求一个转换,该转换的安全检查将在运行时执行
  • 如果已知转换时安全的,则可以使用static_cast强制覆盖编译器的检查
1
2
3
4
5
BulkQuote bulk("awqef", 12, 23, 0.5);
Quote *pq = &bulk;
BulkQuote *pb = pq; // 错误
BulkQuote *pb1 = dynamic_cast<BulkQuote *>(pq);
BulkQuote *pb2 = static_cast<BulkQuote *>(pq);

派生类类型到基类类型的转换

派生类类型到基类类型的转换是不存在的。但可以向基类的拷贝/移动操作传递一个派生类对象,实际运行构造/赋值的运算符将是基类中定义的,此时只能处理基类自己的成员,忽略派生类定义的成员,派生类的部分被slice down

虚函数

由于运行时才知道调用了哪个版本的虚函数,因此所有虚函数必须有定义。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那个

动态绑定只有当通过指针或引用调用虚函数时才会发生,只有在这种情况下对象的静态类型才可能会与动态类型不同。

C++多态

C++中的引用或指针的静态类型与动态类型不同,是C++支持多态性的根本所在。

当通过基类的指针或引用调用基类中的一个函数时,如果是虚函数,则运行时才会依据所绑定对象的真实类型(动态类型)来决定到底执行哪个版本。如果不是虚函数,则解析过程发生在编译时而非运行时。类似的,通过对象进行的函数调用(虚函数或非虚函数)也在编译时绑定。

派生类中的虚函数

一旦某个函数被声明为虚函数,则在所有派生类中都是虚函数。

一个派生类的函数如果覆盖类某个继承而来的虚函数,这它的形参类型必须与被它覆盖的基类函数完全一致。同时,返回值也必须相同。但有下述例外,

1
2
3
4
5
6
7
8
9
10
11
class B {
public:
// ...
virtual B* f();
}

class D : public B {
public:
// ...
D* f();
}

如果虚函数的返回类型是类本身的指针或引用时,返回值可以不同,但要求从D到B的类型转换是可访问的

final和override

  • 如果派生类中的函数与虚函数名字相同形参列表不同(返回值都ok),那么这是合法的,但这个函数与原有虚函数相互独立,并未覆盖
  • 如果派生类中的函数与虚函数名字相同形参列表相同返回值不同,那么这是错误。
1
2
3
4
5
6
class D : public Quote {
public:
D();
int net_price(size_t n) const { return n * price; } // 错误
double net_price(double n) const { return n * price; } // ok,但与原有虚函数相互独立
};

为了避免出现上述的double net_price(double n) const,可以使用override来说明派生类中的虚函数。

只有基类出现过的虚函数,且派生类中的函数声明与虚函数一致时,才能override,否则就会报错。
如果某个函数被指定为final,则之后的任何尝试覆盖该函数的操作都将引发错误。

final和override出现在形参列表(包括const和引用修饰符 )和位置返回类型**之后。

虚函数与默认实参

如果某次函数调用使用了默认实参,则该实参值有本次调用的静态类型(基类中定义默认实参)决定。

回避虚函数机制

有时需要强制执行虚函数某个特定的版本,而不进行动态绑定。这时可以使用作用域运算符,

1
double undiscounted = baseP->Quote::net_price(42);

调用将在编译时完成解析。

通常只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数机制。
下面的调用中,派生类的虚函数调用了基类的版本,如果不回避虚函数机制,那么将会导致无限递归。

1
2
3
4
double D::net_price(double n) const {
Quote::net_price(n);
return n * price;
}

抽象基类

一个纯虚函数无须定义,其中=0只能出现在类内部的虚函数声明的语句处。但也可以为纯虚函数提供定义,不过函数体必须定义在类外部

含有(或者未经覆盖直接继承)的纯虚函数的类似抽象基类

  • 抽象基类负责定义接口,而后续的其他类可以覆盖这个接口
  • 不能直接创建一个抽象基类的对象
  • 如果派生类覆盖了纯虚函数,那么可以创建派生类的对象,否则不能。

派生类构造函数

派生类构造函数只能初始化它的直接基类

1
2
3
4
5
6
7
8
class BulkQuote1 : public DiscQuote {
public:
BulkQuote1() = default;
BulkQuote1(const std::string& book, double price, std::size_t qty,
double disc)
: DiscQuote(book, price, qty, disc) {}
double net_price(std::size_t) const override;
};

BulkQuote1包含三个子对象:空的BulkQuote1,DiscQuote和Quote。

访问控制和继承

派生类的成员或友元只能通过派生类对象来访问基类受保护成员(只能访问派生类对象中的基类部分的受保护成员),派生类对于一个普通的基类对象中的受保护成员没有任何访问特权

public、private和protected继承

派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没有什么影响,这是由基类中的访问说明符决定的。

派生访问说明符控制了派生类用户对于基类成员的访问权限

1
2
3
4
5
6
7
8
9
10
11
12
class B {
public:
void pub_mem();
}

class PrivD : private B {
public:
int f();
}

PrivD d;
d.pub_mem(); // 错误
  • public继承,成员将遵循原有的访问说明符
  • private继承,B的成员是私有的
  • protected继承,B的成员是受保护的

继承自派生类(上面的PrivD)的新类,基类(B)成员的访问权限由派生类(PrivD)的访问说明符决定。即如果是private的,那么新类人不能访问基类(B)的成员。

派生类向基类转换的可访问性

  • 对于用户代码,只有当Dpublic继承B,时,用户代码才能使用D向B的转换
  • 对于直接继承,无论是什么继承D的成员函数和友元都能使用D向B的转换
  • 对于间接继承,只有当Dpublic或protected继承B时,D的派生类的成员函数和友元才能使用D向B的转换

友元与继承

友元不能传递和继承。

改变成员的可访问性

可以用using将类的直接或间接基类中任何可访问成员(非私有)标记出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class B {
public:
void pub_mem();
private:
int prvi;
}

class PrivD : private B {
public:
using B::pub_mem();
using B::prvi; // 错误
int f();
}

PrivD d;
d.pub_mem(); // ok

默认继承保护级别

  • 派生类是struct,默认public继承
  • 派生类是class,默认是private继承

继承中的类作用域

类作用域

每个类都会定义自己的作用域。
Outside the class scope, ordinary data and function members may be accessed only through an object, a reference, or a pointer using a member access operator (§ 4.6, p.150). We access type members from the class using the scope operator. In either case, the name that follows the operator must be a member of the associated class.

定义在类外部的成员

一旦遇到类名,定义的剩余部分就在类的作用域之内(参数列表和函数体)。返回类型中使用的名字都位于类的作用域之外。

名字查找与类的作用域

类的定义分作,

  1. 编译类的声明
  2. 直到类全部可见后才编译函数体

这种两阶段处理的方式只适用于成员函数中使用的名字声明中使用的名字(包括返回值或参数列表中使用的名字),都必须确保在使用前可见

要注意的是,如果成员使用了外层作用域中的某个名字,且该名字代表一种类型,这内层作用域不能重定义这个名字。

1
2
3
4
5
6
7
8
typedef double M;
class A {
public:
M b();
private:
typedef double M; // 错误
M bal;
}

成员函数中使用的名字查找顺序,

  1. 成员函数内查找
  2. 类内查找
  3. 成员函数定义前的作用域内查找(包括类定义之前的全局作用域和成员函数定义前的全局作用域)

派生类的作用域

派生类的作用域嵌套在基类的作用域之内,如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找改名字的定义。

编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DiscQuote : public Quote {
public:
// ...
std::pair<std::size_t, double> discount_policy() const {
return {quantity, discount};
}
// ...
};

BulkQuote bulk;
BulkQuote *pbulk = &bulk;
Quote *pitem = &bulk;
pbulk->discount_policy(); // ok
pitem->discount_policy(); // 错误

这与动态绑定不同。

名字冲突与继承

如果派生类重用定义在其直接基类或间接基类中的名字,则定义在内层作用域(派生类)的名字将隐藏定义在外层作用域(基类)中的名字。

但可以使用作用域运算符覆盖原有的查找规则。一般来说,除了覆盖继承而来的虚函数外,派生类最好不要重用其他定义在基类中的名字。

名字查找先于类型检查

查找顺序,

  1. 先确定静态类型
  2. 在静态类型中查找member,找不到则顺着继承链向上找,最后没找到则报错
  3. 找到后检查函数调用是否合法
  4. 如果合法,
    • 是虚函数且通过引用或指针调用,编译器进行动态绑定
    • 不是,编译器产生一个常规函数调用

声明在内层作用域的函数并不会重载声明在外层作用域的函数,派生类中的函数也不会重载基类中的成员,而是隐藏基类的成员,即使它们的形参列表不一致

虚函数的作用域

假设形参列表不同,会发生隐藏,进而无法通过基类的引用或指针调用派生类的虚函数。

对应于之前提到的两个情况,

  • 如果派生类中的函数与虚函数名字相同形参列表不同(返回值都ok),那么这是合法的,此时隐藏了原有虚函数。
  • 如果派生类中的函数与虚函数名字相同形参列表相同返回值不同,那么这是错误,因为内层作用域中已经存在一个同名且同形参列表的函数了,不允许两个函数除返回值,其他要素都相同。

覆盖重载的函数

成员函数无论是否是虚函数都可被重载,派生类可以覆盖基类中重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么派生类就必须覆盖所有的版本,或者一个也不覆盖。(如果只覆盖部分,此时会隐藏基类的重载函数)

简便的方法是using + 名字(不需要形参列表)。需要保证基类函数的每个实例在派生类中都是可访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class B {
public:
virtual int fcn() {
cout << "base" << endl;
return 1;
}
};

class D : public B {
public:
using B::fcn; // 如果没有,则下面的d.fcn()是错误的
int fcn(int i) {
cout << "derive " << i << endl;
return 1;
}
};

D d;
d.fcn();
d.fcn(1);

构造函数与拷贝控制

如果一个类(基类或派生类)没有定义拷贝控制操作,则编译器会为它合成一个版本。

虚析构函数

由于基类的引用或指针指向继承体系中的某个类型,有可能出现指针的静态类型与被删除对象的动态类型不符的情况。因此需要将析构函数定义为虚函数,以确保执行正确的析构函数版本。

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为(只释放了基类部分的内存)。

基类的析构函数并不需要遵循三/五法则。虚析构函数(即使通过default的形式)将阻止合成移动操作

合成拷贝控制与继承

合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似,它们对类本身的成员依次进行初始化、赋值或销毁。这些合成的成员还负责使用基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁。

无论是基类的合成版本还是自定义版本,都有上述的行为,唯一的要求是新颖的成员是可访问的,且不是一个被删除的函数。

删除拷贝控制

  • 如果基类的构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的,则派生类中的相应成员也是被删除的
  • 如果基类的析构函数是不可访问或删除的,则派生类的默认和拷贝构造函数是被删除的
  • 如果派生类中显式请求编译器生成一个无法合成的移动操作,且基类中的对应操作是删除或不可访问的,派生类中的移动操作将是被删除的
  • 如果基类的析构函数是删除或不可访问的,则派生类中的移动构造函数将是被删除的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class B {
public:
B() = default;
B(const B &) = delete;
virtual ~B() = default;
};

class D : public B {};

B b;
b = b; // ok
b = std::move(b); // ok
B b1(b); // 错误
B b2(std::move(b)); // 错误

由于虚析构函数的存在,编译器不会合成移动操作,因此所有左值和右值的情况(即b = bb = std::move(b)B b1(b)B b2(std::move(b)))都将用拷贝操作处理。又因为拷贝构造函数是删除的,因此两个对拷贝构造函数的调用是错误的。

如果基类没有默认、拷贝或移动构造函数,则一般派生类也不会定义(可以,但必须考虑如何处理基类部分的成员)。

移动操作与继承

由于基类虚析构函数的存在,编译器不会合成移动操作。如果需要移动操作,则应该首先在基类中定义,与此同时必须显式地定义拷贝操作(否则会默认被定义为删除)。

派生类的拷贝控制成员

派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。而析构函数只负责销毁派生类自己分配的资源,派生类对象的基类部分是自动销毁的。

派生类的拷贝和构造函数

定义派生类的拷贝或移动构造函数时,通常使用对应基类的构造函数处理对象的基类部分。

如果未处理使用基类的拷贝或移动构造函数,则基类部分被默认初始化。

派生类赋值运算符

  • 与拷贝和移动构造函数一样,必须显式为基类部分赋值
  • 基类的运算符要能正确处理自赋值的情况
  • 基类运算符将释放左侧运算对象的基类部分的旧值

派生类析构函数

在析构函数执行完成以后,对象的成员会被隐式销毁,对象的基类部分也是隐式销毁的(基类的析构函数被自动调用执行),派生类只负责销毁由派生类自己分配的资源

对象的销毁顺序与创建顺序相反,派生类的析构函数先执行。

在构造函数和析构函数中调用虚函数

在构造或析构派生类对象的过程中,(从基类部分开始构造,派生类部分开始销毁)对象的类型就像是发生了改变一样。当前的构造函数或析构函数不能够调用未构造或已销毁的派生类版本的虚函数(可能会访问派生类部分的成员)。

如果构造函数或析构函数调用了某个虚函数,则应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

1
2
3
4
5
6
7
8
9
10
11
struct Erdos {
Erdos() { whoAmIReally(); }
virtual void whoAmIReally() { cout << "I really am Erdos\n"; }
};

struct Fermat : public Erdos {
virtual void whoAmIReally() { cout << "I really am Fermat\n"; }
};

Erdos s; // I really am Erdos
Fermat f; // I really am Erdos

上述代码中,并非是构造函数发生了继承。

继承的构造函数

这里的构造函数并非以常规的方式继承而来,且类不能继承默认、拷贝和移动构造函数。如果派生类含有自己的数据成员,则它们被默认初始化

1
2
3
4
5
class BulkQuote2 : public DiscQuote {
public:
using DiscQuote::DiscQuote;
// ...
};

上面的的using并不是令DiscQuote的构造函数在这里可见,而是令编译器生成一个与DiscQuote的构造函数对应的派生类构造函数,即

1
2
3
4
BulkQuote2(const std::string& book, double price, std::size_t qty,
double disc)
: DiscQuote(book, price, qty, disc) {
}

注意:

  • 构造函数的using声明并不会改变该构造函数的访问级别
  • 一个using声明不能指定explicit或constexpr,基类的是什么,继承后的也是什么
  • 当一个基类构造函数含有默认实参,这些实参并不会被继承,而是派生类会获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。

何时不继承

  • 如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数不会被继承
  • 不能继承默认、拷贝和移动构造函数,而是按照正常规则被合成。继承的构造函数不会被视为用户定义的构造函数(如果是,则不会合成默认构造函数),因此只包含继承的构造函数(无拷贝和移动构造函数)的类也会有合成的默认构造函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class C {
public:
C(const C &) = default;
};

C c; // 错误

class BulkQuote2 : public DiscQuote {
public:
using DiscQuote::DiscQuote;
// BulkQuote2(const BulkQuote2& b) : DiscQuote(b) {
// std::cout << "copy BulkQuote2" << std::endl;
// }
}

BulkQuote2 b; // ok

由于上面的BulkQuote2只有继承的构造函数,因此编译器会合成一个默认构造函数,故BulkQuote2 b正确。

容器与继承

1
2
3
4
5
6
7
vector<shared_ptr<Quote>> vq;
vq.push_back(make_shared<Quote>("joininnaw", 15));
vq.push_back(make_shared<BulkQuote>("awfawnn", 15, 10, 0.4));

for (const auto &q : vq) {
cout << q->net_price(20) << endl;
}

直接使用vector存储Quote对象是不行的,因为会使用Quote的拷贝构造函数,派生类对象的派生类部分会被截断

派生类的智能指针可以转换为基类的智能指针。

运算符重载

重载的运算符是具有特殊名字的函数,除函数调用运算符外,其他重载运算符不能含有默认实参

  • 一个运算符函数,或者类的成员,或者至少含有一个类类型的参数。也就是说不能为内置类型重载运算符。
  • 只能重载已有的运算符。
  • 一个重载的运算符,其优先级和结合律与对应的内置运算符保持一致
  • 由于使用重载的运算符本质上是函数调用,因此对象求值顺序的规则无法应用到重载的运算符上。尤其是&&||,,两个运算对象总是会被求值。
  • 通常情况下,不应该重载&&||&,,运算符。
  • 一般来说,提供了某个重载运算符,也应该提供与此运算符相关的一系列运算符,如:算术运算符->对应的复合赋值运算符。

成员或者非成员

  • =[]()->运算符**必须是成员。

  • 复合赋值运算符一般来说应该是成员,但非必须。

  • 改变对象状态或与给定类型密切相关的运算符,应该是成员。如:递增、解引用。

  • 具有对称性的运算符可能转换任意一端的运算对象,通常应该是非成员。如:算术、相等性、关系和位运算符。如果想提供含有类对象的混合类型表达式,运算符必须是非成员函数。

    1
    2
    string s = "world";
    string u = "hi" + s; // 如果是成员函数,则"hi".operator+(s),错误

对于友元要注意,在类内部虽然有友元声明,但这并非真正意义上的函数声明,因此在类外部还需要有函数声明。

输入输出运算符

<<

  • ostream &operator<<(ostream &os, const Sales_data &item);
  • 必须是非成员函数,否则左侧运算对象将是类的一个对象
    就算要是某个类的成员,也只能是istream或ostream的,然而并不能为标准库中的类添加成员,因此只能是非成员函数
  • 不应该打印换行符

>>

  • istream &operator>>(istream &is, Sales_data &item);

  • 必须处理输入可能失败的情况

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    istream& operator>>(istream &is, Sales_data &item) {
    double price = 0.0;
    is >> item.bookNo >> item.units_sold >> price;
    if (is) {
    item.revenue = price * item.units_sold;
    } else {
    item = Sales_data();
    }
    return is;
    }
  • 有时需要标识流的条件状态

算术和关系运算符

  • 通常把算术和关系运算符定义为非成员函数,使得左侧或右侧的运算对象可以转换

    1
    2
    string s = "world";
    string u = "hi" + s; // 如果是成员函数,则"hi".operator+(s),错误
  • 这些运算符一般不需要改变运算对象的状态,所以形参是常量引用

  • 一般使用复合赋值来实现算术运算符

==

  • 相等运算符应该具有传递性
  • 定义了==,也应该定义!=

关系运算符

关系运算符应该,

  1. 定义顺序关系,且与关联容器中对关键字的要求一致
  2. Define a relation that is consistent with == if the class has both operators. In particular, if two objects are !=, then one object should be < the other.

如果存在唯一一种逻辑可靠的<语义,才考虑定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。

=

  • 必须为成员函数
  • 必须先释放当前内存空间
  • 如果不是拷贝赋值运算符和移动赋值运算符,则不必检查自赋值的情况。

[]

  • 必须为成员函数
  • 返回元素的引用
  • 通常定义const和非const版本

递增递减运算符

由于改变了对象的状态,一般应为成员函数,且应该同时定义前置版本和后置版本。

1
2
3
4
5
6
7
8
9
10
// 返回递增或递减后对象的引用
StrBlobPtr &operator++();
StrBlobPtr &operator--();

// 返回对象的原值
StrBlobPtr operator++(int); // 形参不会被使用,仅仅是和前置版本进行区分
StrBlobPtr operator--(int);

p.operator--(0); // 调用后置版本的
p.operator--(); // 调用前置版本的

成员访问运算符

1
2
3
4
5
6
string &StrBlobPtr::operator*() const {
auto p = check(curr, "deference past end");
return (*p)[curr];
}

string *StrBlobPtr::operator->() const { return &this->operator*(); }
  • ->必须是类成员*非必需,但通常也是
  • 定义为const是因为获取一个元素并不会改变对象的状态。
  • 虽然可以让解引用返回任何想要的值或打印(不建议),但箭头运算符必须有成员访问的含义。重载箭头运算符时,可以改变的是箭头从哪个对象中获取成员。

函数调用运算符

函数调用运算符必须是成员函数。如果类定义了调用运算符,那么该类的对象叫做函数对象。调用函数对象实际上是在运行重载的调用运算符。

lambda是函数对象

编写lambda后,编译器将生成一个未命名类的未命名对象。这个类中有一个重载的函数调用运算符,且默认情况下这个成员是const(lambda不能改变捕获的变量),除非lambda被声明为mutable。

  • 引用捕获
    由程序确保lambda执行时所引用的对象确实存在,生成的类中无须保存为数据成员。

  • 值捕获
    生成的类中需建立对象的数据成员,同时创建构造函数,用捕获的变量来初始化数据成员。

lambda产生的类中,不包含默认构造函数、赋值运算符和默认析构函数,默认拷贝和默认移动构造函数视捕获的数据成员类型而定。

标准库定义的函数对象

标准库规定的函数对于指针同样适用。直接比较两个无关的指针将产生未定义的行为,但通过标准库定义的函数对象来比较是定义良好的

关联容器使用less<key_type>对元素排序,因此可以直接定义一个指针的set或map,而无须声明less

function

C++中的可调用对象多种有,它们的类型是不同的,

  • 函数和函数指针
    类型由返回值类型和实参类型决定
  • lambda表达式
    每个lambda有唯一的未命名类类型
  • bind创建的对象
  • 重载了函数调用运算符的类

类型不同的可调用对象可能共享同一种调用形式。调用形式指明了调用返回的类型以及传递给调用的实参类型。一种调用形式对应一个函数类型。

定义funcion类型时,需要指明调用形式。

1
2
3
4
5
map<string, function<int(int, int)>> binops = {{"+", plus<int>()},
{"-", minus<int>()},
{"*", multiplies<int>()},
{"/", divides<int>()},
{"%", modulus<int>()}};

重载、类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类型的隐式转换机制,这种构造函数叫做转换构造函数。

转换构造函数类型转换运算符共同定义类类型转换,也叫用户定义的类型转换。

编译器一次只能执行一个用户定义的类型转换。

隐式类型转换运算符

  • operator int() const;
    从类类型转换为int
  • 无显示的返回类型和形参
  • 返回一个对应类型的值
  • 必须定义为成员函数
  • 通常不应该改变转换对象的内容,一般被定义为const
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class S {
public:
S(int i = 0) : val(i) { cout << "construct" << endl; }
S(const S &s) : val(s.val) { cout << "copy" << endl; }
S(S &&s) : val(s.val) { cout << "move" << endl; }
S &operator=(const S &s) {
cout << "copy assign" << endl;
val = s.val;
return *this;
}
S &operator=(S &&s) {
cout << "move assign" << endl;
val = s.val;
return *this;
}
operator int() const {
cout << "conversion->int" << endl;
return val;
}

private:
size_t val;
};

S s; // construct
s = 4; // construct, move assign
s + 3; // conversion->int
s - s; // conversion->int, conversion->int

S虽然没有定义-,但隐式转换为int,可以执行内置的-

虽然编译器一次只能执行一个用户定义的类型转换,但隐式的用户定义类型转换可以至于一个标准(内置)类型转换之前或之后

1
2
3
4
// double->int->S
s = 3.14; // construct, move assign
// S->int->double
s + 3.14; // conversion->int

显式类型转换运算符

explicit operator int() const;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class S {
public:
S(int i = 0) : val(i) {}
explicit operator int() const { return val; }

private:
size_t val;
};

S s;
s + 3; // 错误
static_cast<int>(s) + 3;

if (s) {
// ...
}

但是当表达式作为条件时,编译器会将显式类型转换自动应用于它(仅仅是自动应用explicit operator bool() const,以转换为bool)。

1
2
3
4
5
6
7
8
9
10
11
12
class S {
public:
S(int i = 0) : val(i) {}
explicit operator bool() const { return val; }

private:
size_t val;
};

S s;
if (s) {
}

避免有二义性的类型转换

必须确保类类型和目标类型之间只存在唯一一种转换方式。无法通过使用强制类型转换来解决二义性问题,强制类型转换也会面临二义性问题。

多重转换路径可能由于,

  • 两个类提供相同的类型转换

  • 类定义了一组类型转换,它们的转换源(或者转换目标)类型本身可以通过其他类型转换联系在一起,由于所有算术类型转换的级别都一样,选择转换序列时会有多个转换序列,将会导致二义性。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class S {
    public:
    S();
    operator int() const;
    operator double() const;
    };

    S s;
    int a = s; // 精确匹配
    float b = s; // 两个“可行函数”

如果已经定义了一个转换为算术类型的类型转换,不要再定义接受算术类型的重载运算符。在不定义以后,如果用户需要使用这样的运算符,则类型转换操作会转换此类型的对象,然后使用内置的运算符

重载函数与转换构造函数

当调用重载的函数时,如果两个或多个类型转换都提供了可行的匹配,则这些类型转换一样好。

重载函数与用户定义的类型转换

当调用重载的函数时,如果两个或多个类型用户定义的转换都提供了可行的匹配,则这些类型转换一样好。此时,不考虑任何可能出现的标准类型转换级别。

只有当重载函数能通过同一个类型转换函数得到匹配时(所有可行函数都请求同一个用户定义的类型转换),才考虑标准类型转换级别。

函数匹配与重载运算符

表达式中运算符的候选函数集包括成员函数非成员函数

如果对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符和内置运算符的二义性问题

拷贝、赋值和销毁

拷贝构造函数

拷贝构造函数的第一个参数必须是一个引用类型,且几乎总是一个const引用。由于拷贝构造函数在多个情况下会被隐式使用,因此不能是explict的。

F {
1
2
3
4
 public:
Foo();
Foo(const Foo&);
}

合成拷贝构造函数

如果没有定义拷贝构造函数,编译器会定义一个合成拷贝构造函数。不同于合成默认构造函数r,即使自己定义了其它的拷贝构造函数,编译器也会合成一个拷贝构造函数。

合成拷贝构造函数会将参数的每个非static成员逐个拷贝到正在创建的对象中。拷贝方式依据成员类型而定,

  • 对于类类型,会使用其拷贝构造函数;
  • 对于内置类型,会直接拷贝;
  • 如果成员有数组类型,合成拷贝构造函数会逐元素的拷贝。

合成的函数会被隐式地声明为内联的

拷贝初始化

直接初始化会选择与参数最匹配的构造函数。拷贝初始化是件右侧运算对象拷贝到正在创建的对象中。

拷贝初始化通常使用拷贝构造函数来完成,以下情况会发生拷贝初始化,

  • =定义变量;
  • 将对象作为实参传递给一个非引用类型的形参;
  • 用一个返回类型为非引用类型的函数返回一个对象;
  • 用列表初始化一个数组中的元素或一个聚合类中的成员;
  • 某些类类型会对它们所分配的对象使用拷贝初始化(如:容器的insert)。

在进行拷贝初始化时,编译器可以跳过拷贝/移动构造函数,直接创建对象,但此时拷贝/移动构造函数必须存在且可访问

如上文所说,拷贝构造函数会被隐式使用,下面是几个例子,

1
2
3
4
5
class C {
// ...
};
C c;
vector<C> vc{c};

上述代码中,对vector使用列表初始化时,c会被copy两次。1. initializer_list的构造函数会copy一次,2. 从initializer_list到设计存储位置还会copy一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class C {
// ...
};
void f(C c) {
C tmp(0);
tmp = c;
}
void fr(C &c) {
C tmp(0);
tmp = c;
}

C obj;

f(obj);
f(C(1));
fr(obj);
fr(C(4));
  • 第一个f调用,首先会用obj对形参c做拷贝初始化,然后用拷贝赋值,将c赋值给tmp;
  • 第二个f调用,这里并没有用临时对象对形参c做拷贝初始化,而是用临时对象C(1)对tmp进行复制。因为有copy elision。
  • 第一个fr调用,直接拷贝赋值,将obj赋值给tmp;
  • 第二个fr调用是错误的,问题类似取临时对象的地址,这里C(4)是rvalue表达式,而fr()需要一个左值作为参数。

拷贝赋值运算符

拷贝赋值运算符执行与析构函数和拷贝构造函数相同的工作。
如果没有定义拷贝赋值运算符,编译器会定义一个合成拷贝赋值运算符。

重载赋值运算符

赋值运算符就是一个名为operator=的函数,其参数表示要收费的运算对象。定义为成员函数的运算符,其左侧运算对象就绑定到隐式的this参数。返回值通常为左侧运算对象的引用

拷贝赋值运算符参数应为与所在类相同类型的参数

合成拷贝赋值运算符

合成拷贝赋值运算符会将右侧运算对象的每个非static成员赋予左侧运算对象的相应成员。类似拷贝构造函数逐个拷贝成员,这一工作是由成员类型的拷贝赋值运算符完成的。如果是数组类型的成员,则逐个赋值数组元素

如果自己定义的拷贝赋值运算符或拷贝构造函数没有处理成员中的数组,逐个拷贝/赋值不会发生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class HasPtr {
public:
HasPtr(const string &s = string()) : ps(new string(s)), i(0) {}
HasPtr(const HasPtr &rhs) : ps(new string(*rhs.ps)), i(rhs.i) {}

string *ps;
int i;
int arr[10];
array<int, 10> sarr;
};

HasPtr a("abc");
a.arr[0] = 232;
a.arr[1] = 232;
a.sarr[0] = 232;
a.sarr[1] = 232;

HasPtr b = a;
HasPtr c;
c = a;
(*b.ps).append("a");
cout << *a.ps << " " << *b.ps << " " << *c.ps << endl;
// abc abca abc
cout << a.arr[0] << " " << b.arr[0] << " " << b.sarr[0] << endl;
// user defined copy constructor
// 232 32627 32627
cout << a.sarr[0] << " " << c.arr[0] << " " << c.sarr[0] << endl;
// synthesized copy-assignment operator
// 232 232 232

析构函数

销毁对象的非static成员。没有返回值,不接受参数(因此不能够被重载)。对于一个给定类,只会有唯一一个析构函数。

销毁顺序按照初始化顺序的逆序进行,销毁内置类型成员不需要做什么,销毁类类型成员需要执行成员自己的析构函数,销毁内置指针类型的成员不会delete所指向的对象。

析构函数在以下情况进行调用,

  • 变量离开其作用域;
  • 一个对象呗销毁时,其成员也被销毁;
  • 容器(标准库容器和数组)被销毁时,其元素被销毁;
  • 对动态分配的对象,其指针使用delete;
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

合成析构函数

合成析构函数函数体为空,当其函数体执行完以后,成员会被自动销毁。析构函数函数体并不直接销毁成员,成员是在析构函数函数体之后隐含的析构阶段中被销毁的。

三/五法则

  • 需要析构函数的类也需要拷贝构造函数和拷贝赋值运算符。
  • 需要拷贝操作的类也需要赋值操作,反之亦然,但不必然意味着也需要析构函数
  • 拷贝操作会带来额外的开销,在拷贝不必须的情况下,应加入移动操作。

阻止拷贝

类似=default,使用=delete可以定义删除的函数。=delete必须出现在函数第一次声明的时候,且可以回任何函数指定=delete

如果一个类的析构函数或者一个成员的析构函数是=delete,那么将无法定义该类型的变量或创建该类的临时对象,可以new但无法delete。

关于合成拷贝控制成员,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的。

拷贝控制和资源管理

行为像值的类

行为像值的类有自己状态,副本和原对象是完全独立的。

由于赋值操作会销毁左侧运算对象的资源,在对如下类定义拷贝赋值运算符时,需要考虑将一个对象赋值给自身的情况。

1
2
3
4
5
6
7
8
9
10
11
class HasPtr1 {
public:
HasPtr1(const string &s = string()) : ps(new string(s)), i(0) {}
HasPtr1(const HasPtr1 &rhs) : ps(new string(*rhs.ps)), i(rhs.i) {}
HasPtr1 &operator=(const HasPtr1 &rhs);
~HasPtr1() { delete ps; }

private:
int i;
string *ps;
};

如果拷贝赋值运算符是这样的,

1
2
3
4
5
6
7
8
HasPtr1 &operator=(const HasPtr1 &rhs) {
delete ps;
ps = new string(*rhs.ps);
ai = rhs.i;
return *this;
}
HasPtr1 h;
h = h;

将一个对象赋值给自身时,解引用*rhs.ps就是错误的,因为ps所指向的对象已经被delete了。因此需要用一个局部临时对象先保存右侧运算符对象的资源

1
2
3
4
5
6
7
HasPtr1 &operator=(const HasPtr1 &rhs) {
auto newp = new string(*rhs.ps);
delete ps;
ps = newp;
ai = rhs.i;
return *this;
}

行为像指针的类

行为像值的类共享状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class HasPtr2 {
public:
HasPtr2(const string &s = string())
: ps(new string(s)), i(0), use(new size_t(1)) {}
HasPtr2(const HasPtr2 &rhs) : ps(rhs.ps), i(rhs.i), use(rhs.use) { ++*use; }
HasPtr2 &operator=(const HasPtr2 &rhs);
~HasPtr2() {
if (--*use == 0) {
delete ps;
delete use;
}
}

private:
string *ps;
int i;
size_t *use;
};

对于以上类的拷贝构造函数,需要递增右侧运算对象的引用计数,递减左侧运算对象的引用计数。这里同样需要考虑同一个对象给自身赋值的情况,应该先递增右侧运算对象的引用计数,然后递减左侧的并检查,

1
2
3
4
5
6
7
8
9
10
11
HasPtr2 &operator=(const HasPtr2 &rhs) {
++*rhs.use;
if (--*use == 0) {
delete ps;
delete use;
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;
}

交换操作

如果一个类没有定义自己的swap,需要的时候将调用标准库的swap。一般来说,一次swap需要一次copy和两次assign,但这并不是必须要的。如果一个类有动态分配的内存,可以交换指针,而不是既copy又assign。

1
2
3
4
5
6
7
8
9
10
11
class HasPtr {
public:
friend swap(HasPtr &lhs, HasPtr &rhs);
// ...
}

inline void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}

要注意的是:

  1. HasPtr的成员是内置类型,并没有特定版本的swap,因此上述swap中调用的是标准库的swap;
  2. 如果一个类的成员有自己类型特定的swap,那么调用std::swap就是错误的,标准库swap会进行不必要的copy。
    1
    2
    3
    4
    void swap(Foo &lhs, Foo &rhs) {
    using std::swap;
    swap(lhs.h, rhs.h); // 使用HasPtr的swap
    }
    如果存在类型特定的swap,其匹配程度会优于std中定义的版本。上面的using std::swap;并未隐藏HasPtr的swap。

copy and swap

1
2
3
4
HasPtr& HasPtr::operator=(HasPtr rhs) {
swap(*this, rhs);
return *this;
}

这里的赋值运算符是用传值的方式。swap左侧运算对象和副本,然后销毁副本。

copy and swap天然就是异常安全的,因为可能抛出异常的情况就是传值时候的copy,如果此时抛出异常,左侧对象不会被修改。同时保证了自赋值的正确,因为是copy。

move

lvalue和rvalue

lvalue:有持久的状态,可以取地址。
rvalue:字面值常量或临时对象,不可以取地址。

rvalue reference

必须绑定到右值,即要求转换的表达式、字面值常量或返回右值的表达式。但不能直接绑定到一个左值上。

1
2
3
4
int i = 5;
int &r1 = i * 42; // 错误i*42是右值
const int &r1 = i * 42;
int &&rr1 = i * 42;

由于rvalue reference只能绑定到临时对象,因此这个对象,

  • 将要被销毁
  • 没有其他用户

这意味着使用rvalue reference可以自由地接管所引用对象的资源。

1
2
3
int i = 5;
int &&rr1 = i * 42;
int &&rr2 = rr1; // 错误

右值引用类型变量

进行右值引用后,得到的右值引用类型变量是左值

1
2
3
4
5
int i = 32;
int &&rr1 = i * 32;
cout << &i << endl; // 0x71d77768be28
cout << &rr1 << endl; // 0x71d77768be2c
int &&rr2 = rr1; // 错误rr1是左值

要将右值引用绑定到一个左值,应该显示地转换或move,

1
2
3
4
int i = 5;
int &&rri = static_cast<int &&>(i);
int &&rri1 = std::move(i);
// int &&rri2 = i;

使用move后,不能对移后源对象的值做任何假设,不能使用移后源对象的值除了对rri赋值或销毁外,不能再使用它。

移动构造函数和移动赋值运算符

移动构造函数

参数是一个右值引用,且任何额外的参数都必须有默认实参。完成移动后,必须保证销毁源对象是无害的。一旦完成移动,源对象必须不能再指向被移动的资源,资源所有权已归属新创建的对象。

1
2
3
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = nullptr;
}

移动构造函数通常不分配任何新内存,因此通常不抛出异常。为避免标准库为了处理可能抛出异常而做的额外工作,一种通知标准库的方法是指明noexcept

移动操作和异常

  • 虽然移动操作通常不抛出异常,但是抛出异常是允许的。
  • 标准库能够对异常发生是其自身的行为提供保障。
1
2
3
4
vector<StrVec> vs;
// reallocate vecotr
std::move(vs[i]);
// ...
  • 上面的代码中,如果在reallocate时,移动了部分元素后抛出异常,那么问题就会发生,源vector已经改变,但是新空间有的元素还不存在。
  • 如果vector使用拷贝构造函数且发生了异常,那么不会影响源vector。

基于上面两点,除非vector知道元素类型的移动构造函数不会抛出异常,否则在reallocate时,就必须使用拷贝构造函数(即前面所说的,为了处理可能抛出异常而做的额外工作)。如果希望在类似reallocate 的情况下使用移动而非拷贝,就必须显式的告诉标准库移动构造函数可以安全使用。

移动赋值运算符

移动赋值运算符执行与析构函数和移动构造函数相同的工作。如果不抛出异常,则应该标记为noexcept

1
2
3
4
5
6
7
8
9
10
StrVec &operator=(StrVec &&rhs) noexcept {
if (this != &rhs) { // 处理自赋值
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}

这里必须check是否是同一对象,因为此右值可能是move调用返回的结果,再者不能在使用右侧运算对象的资源之前就释放左侧运算对象的资源。

移后源对象必须可析构

从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁,因此需确保移后源对象必须可析构。

移动操作还应保证对象仍是有效的,即可以安全地为其赋予新值或可以安全地使用不依赖其当前值

移动后,源对象的值是没有保证的,不应依赖移后源对象中的数据。

合成移动操作

何时定义

1
2
3
4
5
6
7
8
9
10
11
struct X {
int i;
string s;
};

struct hasX {
X mem;
};

X x, x2 = std::move(x); // 合成移动构造函数
hasX hx, hx2 = std::move(hx); // 合成移动构造函数

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动构造或移动赋值时,编译器才会合成移动构造函数和移动赋值运算符。

何时删除

1
2
3
4
5
6
7
8
9
10
11
12
struct Y {
Y(const Y &y) {}
int i;
string s;
};

struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y mem;
};
hasY hy, hy2 = std::move(hy); // call to implicitly-deleted default constructor of 'hasY'

移动操作永远不会隐式定义为delete。但如果显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员时,移动操作会被定义为delete。

定义了一个移动构造函数或移动赋值运算符的类必须也定义拷贝操作,否则这些成员默认定义为delete

如果未定义

1
2
3
4
5
6
7
8
class Foo {
public:
Foo() = default;
Foo(const Foo &) { cout << "copied" << endl; }
};
Foo foo;
Foo foo1(foo); // copied
Foo foo2(std::move(foo1)); // copied

如果没有移动构造函数,就算试图调用move来移动,对象也会被拷贝。

这里与上面的hasY不同,hasY的移动构造函数是delete的(由于显式地要求生成,但编译器无法生成)。而这里的仅仅是未定义,函数匹配保证该类型的对象会被copy。

copy and swap again

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Hp {
public:
Hp() = default;
Hp(const Hp &) { cout << "copied" << endl; }
Hp(Hp &&) { cout << "moved" << endl; }
Hp &operator=(Hp rhs) {
cout << "assign" << endl;
return *this;
}
};

Hp hp1;
Hp hp2 = hp1; // copied,拷贝构造函数
Hp hp3 = std::move(hp1); // moved,移动构造函数
cout << "---" << endl;
hp1 = hp3; // copied assign,拷贝赋值运算符
hp2 = std::move(hp3); // moved assign,移动赋值运算符

这里除了拷贝构造函数,还有移动构造函数和赋值运算符。对于之前未定义移动构造函数的情况下,调用赋值运算符,初始化形参时总是进行拷贝

现在拷贝初始化依赖于实参的类型,

  • 左值->使用拷贝构造函数进行初始化,赋值运算符为拷贝赋值运算符
  • 右值->使用移动构造函数进行初始化,赋值运算符为移动赋值运算符

从而单一的赋值运算符,实现了两种功能。

copy and swap idiom

上面的将拷贝赋值运算符和移动赋值运算符“合并”到一起的方式叫做copy and swap idiom。如果按照上面的方式实现了拷贝赋值运算符和移动赋值运算符,就不能再单独写两个拷贝赋值运算符和移动赋值运算符。

而两种实现拷贝赋值运算符和移动赋值运算符的效率是有区别的(C++ Primer 5th Chinese Edition, Exercise 15.53)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#define NDEBUG

class HasPtr3 {
friend void swap(HasPtr3&, HasPtr3&);
friend bool operator<(const HasPtr3& lhs, const HasPtr3& rhs);

public:
HasPtr3(const std::string& s = std::string())
: ps(new std::string(s)), i(0) {}
HasPtr3(const HasPtr3& hp) : ps(new std::string(*hp.ps)), i(hp.i) {
#ifndef NDEBUG
std::cout << "copied" << std::endl;
#endif
}
HasPtr3(HasPtr3&& hp) noexcept : ps(hp.ps), i(hp.i) {
hp.ps = nullptr;
#ifndef NDEBUG
std::cout << "moved" << std::endl;
#endif
}

// = 4s
// mixed 4.8s
HasPtr3& operator=(const HasPtr3& rhs) {
auto newp = new std::string(*rhs.ps);
delete ps;
ps = newp;
i = rhs.i;
#ifndef NDEBUG
std::cout << "copy assigned" << std::endl;
#endif
return *this;
}

// move 0.7s
// mixed 4.8s
HasPtr3& operator=(HasPtr3&& rhs) noexcept {
if (this != &rhs) {
delete ps;
ps = rhs.ps;
i = rhs.i;
rhs.ps = nullptr;
#ifndef NDEBUG
std::cout << "move assigned" << std::endl;
#endif
}
return *this;
}

// = 6s
// move 2.5s
// mixed 8.8s
// HasPtr3& operator=(HasPtr3 rhs) {
// swap(*this, rhs);
// #ifndef NDEBUG
// std::cout << "assigned" << std::endl;
// #endif
// return *this;
// }

~HasPtr3() { delete ps; }

private:
std::string* ps;
int i;
};

inline void swap(HasPtr3& lhs, HasPtr3& rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // swap the pointers, not the string data
swap(lhs.i, rhs.i); // swap the int members

#ifndef NDEBUG
std::cout << "swapped" << std::endl;
#endif
}

测试代码如下,这里最好不要把对象的创建放到循环中去,每次构造和销毁的开销会使得两种拷贝和移动的区别不太明显,

1
2
3
4
5
6
7
8
9
10
HasPtr3 hp;
HasPtr3 hp1;
HasPtr3 hp2;
auto t0 = high_resolution_clock::now();
for (int i = 0; i < 100000000; ++i) {
hp = hp1;
hp = std::move(hp2);
}
auto t1 = high_resolution_clock::now();
cout << duration_cast<milliseconds>(t1 - t0).count() << "ms" << endl;

结果如下(VMware 12, Archlinux x64, Intel i7-2620m),

hp = hp1; hp = std::move(hp2); mixed
HasPtr3& operator=(const HasPtr3& rhs) 4s N/A 4.8s
HasPtr3& operator=(HasPtr3&& rhs) noexcept N/A 0.7s 4.8s
HasPtr3& operator=(HasPtr3 rhs) 6s 2.5s 8.8s

可看出copy and swap idiom的效率是不如分开写好。

移动迭代器

一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。对移动迭代器解引用生成的是一个右值

1
auto newe = uninitialized_copy(make_move_iterator(elements), make_move_iterator(cap), newb);

通过调用make_move_iterator可将一个普通迭代器转换为一个移动迭代器。上面的代码中,传递给uninitialized_copy的是一个移动迭代器,解引用后得到的是右值,因此uninitialized_copy将使用移动构造函数来构造元素。

标准库不保证哪些算法适用于移动迭代器。只有确认对象在传递给函数后不再访问,才能将移动迭代器传递给算法。

右值引用和成员函数

类似构造函数和赋值运算符,成员函数同样可以提供拷贝版本和移动版本。

1
2
void push_back(const X&); // 拷贝
void push_back(X&&); // 移动

拷贝版本接受能够转换为类型X的任何对象。使用const X&是因为拷贝操作不应该改变该对象

而移动版本接受非const右值,对于非const右值是精确匹配。从源对象移动数据时,显然需要更改源对象,所以是X&&

右值和左值引用成员函数

对于赋值运算符,为了强制左侧运算对象是一个左值,可以类似const,在参数列表后使用引用限定符,引用限定符可以是&&&

1
Foo &operator=(const Foo&) &; // 只能向可修改的左值赋值

const改变了this指针的类型,指明了this是指向常量的指针,这里类似,引用限定符说明了this可以指向一个左值还是右值。const和引用限定符只能用于非static成员函数。

重载和引用函数

  • 当定义const成员函数是,可以根据有无const,定义两个重载版本。

    1
    2
    Foo sorted();
    Foo sorted() const;
  • 当定义有引用限定符的成员函数时,如果定义两个或两个以上具有相同名字和参数列表的成员函数,就必须对*所有重载函数都加上引用限定符。

    1
    2
    3
    Foo sorted(Comp*) &&;
    Foo sorted(Comp*) const; // 错误
    Foo sorted(Comp*) const &;

静态内存:存储局部static对象、类static数据成员和定义在函数之外的变量。
static对象:使用之前分配,程序结束时销毁。

栈内存:保存定义在函数内部的非static对象。
栈对象:仅在定义的程序块运行时才存在。

动态内存(free store或heap):存储动态分配的对象,需要显示地销毁,分配和销毁由newdelete完成。

智能指针

shared_ptr

最安全的分配和使用动态内存的方法是调用make_shared,返回指向在动态内存分配的对象的shared_ptrmake_shared类似emplace,使用参数来构造指定类型的对象,如果没有参数,则进行值初始化。

当进行copy或assign时,每个shared_ptr会记录有多少个其他shared_ptr指向相同的对象。可看作shared_ptr有reference count,

  • 当发生以下情况时,count递增;

    1. copy或assign;
      1
      2
      shared_ptr<string> p = make_shared<string>("hello"); // 1
      auto q(p); // 2
    2. 作为参数传给一个函数;
    3. 作为函数的返回值;
  • 当发生以下情况时,count递减;

    1. shared_ptr赋予一个新的值;
      1
      2
      shared_ptr<int> r = make_shared<int>(42); // 1
      r = q; // ++q指向对象的ref count,--r指向对象的ref count;销毁r原来指向对象
    2. shared_ptr被销毁;

count的递减由shared_ptr的析构函数完成,如果count变为0shared_ptr会释放所管理的对象。

在某个scope中,只要能够使用shared_ptr,那么它的引用计数至少为1

操作

1
2
3
4
5
6
p.get() // 返回内置的指针
p.use_count() // 返回引用计数
p.unique() // 若引用计数为1,则返回true;否则false
p.reset() // 将p置空,若p是唯一指向对象的,则释放此对象
p.reset(q) // 令p指向内置指针q,若p是唯一指向对象的,则释放此对象
p.reset(q, d) // 同上,释放q时调用d

使用newdelete会使得类对象的copy、assign和destroy不能依赖任何默认定义

自定义释放操作

默认情况下,shared_ptr指向的是动态内存,因此被销毁时,默认调用delete。可以自定义释放操作,提供其他的deleter。deleter的参数必须为该shared_ptr的内置指针类型

直接管理动态内存

new

默认情况下,new的对象是默认初始化的。也可以使用值初始化的方式来初始化new的对象(圆括号+参数),还可以使用列表初始化,以及值初始化(空括号)。

自动推断类型

可以使用auto从initializer来推断将要分配的对象类型,由于编译器需要从initializer来获得类型,因此圆括号中仅能有一个initializer,

1
2
3
auto p1 = new auto(obj);
auto p2 = new auto{a, b, c}; // 错误
auto p3 = new auto{a}; // 错误

const对象

和其他const对象相同,必须初始化。

1
2
const string *pcs = new const string; // 调用默认构造函数
const int *pci = new const int(1024); // 只能显示初始化

bad_alloc

如果内存不足,new失败,就会抛出bad_alloc,但可以告知不抛出。

1
2
int *p1 = new int;
int *p2 = new (nothrow) int; // 失败则返回nullptr

delete

传递给delete的必须是指针,且必须指向动态分配的内存,或是一个nullptr。如果是动态分配的内存,或释放同一个指针多次,行为未定义。对const动态对象,销毁的方法也是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用clang++ 3.7编译
int i = 5;
int *pi = &i;
delete pi; // segmentation fault

int *pi2 = nullptr;
delete pi2; // ok

double *pd = new double(3);
double *pd1 = pd;
delete pd;
cout << pd << endl;
// pd不会被置为nullptr,空悬指针
delete pd; // core dumpped

const int *pci = new const int(1024);
delete pci;

可以在delete后手动赋值为nullptr。但也仅仅只解决了pd的问题,多个指针指向同一个内存区域时,仍然有问题,pd1仍然指向原内存区域,还是空悬指针。

shared_ptr和new

可以用new返回的指针来初始化shared_ptr。由于接受智能指针的构造函数是explicit的,因此必须使用直接初始化

1
2
shared_ptr<int> p(new int(1024));
shared_ptr<int> p1 = new int(1024); // 错误,此语句首先需要在int*和shared_ptr之间做隐式转换,然后再把临时的shared_ptr拷贝给p1。

shared_ptr定义了get函数,可以获得内置指针,指向shared_ptr管理的对象。通过这种方式得到的指针不能被delete,必须保证代码不会delete的情况下,才能使用get。

1
2
3
4
5
6
7
8
9
10
// 使用clang++ 3.7编译
shared_ptr<int> p(make_shared<int>(4));
int *q = p.get();
{
shared_ptr<int> sq(q);
cout << *sq << endl; // 4
delete q; // double free or corruption
}
cout << *p << endl; // 错误,p指向的内存已被销毁
// double free or corruption

上述代码在内部的scope中手动删除了p指向的内存,当这个scope结束时,sq被销毁,那部分内存还会被shared_ptr销毁一次。编译时不会报错,但运行时出现double free or corruption。
就算没有delete,内部的scope结束,那部分内存被销毁,这段代码结束时,又一次被销毁,同样也会有double free or corruption。

智能指针和异常

无论是函数正常结束或者发生异常,局部对象都会被销毁。智能指针被销毁时,如果引用计数为0,则释放内存。但new得到的内存不会被自动释放,如果有指向这块内存的指针,只有指针会被销毁。

unique_ptr

unique_ptr拥有指向的对象。没有类似make_shared的函数,只能将其绑定到new返回的指针上。也是必须使用直接初始化。

操作

除了将被销毁的unique_ptr外,不支持copy和assignment

1
2
3
4
5
u = nullptr // 释放u指向的对象,并置空
u.release() // 放弃对指针的控制权,返回指针并将u置空
u.reset() // 类似shared_ptr,只是无需判断引用计数
u.reset(q)
u.reset(nullptr)

自定义释放操作

不同于shared_ptr,unique_ptr在重载deleter时,需要提供deleter的类型。重载unique_ptr的deleter,会影响到unique_ptr的类型和如何构造或reset该类型的对象。

1
unique_ptr<objT, delT> p(new objT, fcn);

weak_ptr

  • 不控制所指向对象的生命周期;
  • weak_ptr指向shared_ptr管理的对象;
  • 将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数
  • 一旦shared_ptr被销毁,所指对象就被释放。

操作

1
2
3
4
5
w = p // p可以是weak_ptr或shared_ptr
w.reset() // 将w置空
w.use_count()
w.expired() // 若w.use_count()为0,返回true,否则false
w.lock() // 若w.expired()为true,返回空的shared_ptr,否则返回指向w的对象的shared_ptr

动态数组

分配动态数组的类必须定义自己的版本的操作来管理拷贝,复制以及销毁

new数组

1
int *pia = new int[32];

pia中的元素是进行默认初始化的。但此时pia并不是一个数组类型的对象,只是一个数组元素类型的指针,因此不能够调用begin和end(它们使用数组的维度来得到首元素和尾后元素指针),也不能使用for。

初始化

new的数组和单个对象一样,默认情况下,new的数组是默认初始化的。可以对数组中的元素进行值初始化和列表初始化,也和单个对象一样。

1
2
3
string *psa = new string[10]();
auto *psa1 = new string[10]("abd", "abc", ...); // 不能再这里提供initializer,因此不能用auto
int *pia = new[10]{0, 1, 2, 3, 4, 5}; // 剩下的元素进行值初始化

如果new失败,类似bad_alloc,这里会抛出bad_array_new_length。

new空数组

这样做是合法的,得到的是一个合法的非空指针,相当与数组的尾后指针,不能解引用。

1
int *p = new int[0]; // 合法,但不可以解引用

释放动态数组

1
2
3
int *p = new int[10];
delete [] p;
delete p; // 未定义

释放时,按逆序销毁。p还可以为nullptr。

智能指针和动态数组

标准库提供了一个管理new分配的数组的unique_ptr,但此unique_ptr不支持成员访问运算符。unique_ptr被销毁时,会自动使用delete []

1
2
3
unique_ptr<T[]> u
unique_ptr<T[]> u(p)
u[i]

如果使用shared_ptr来管理,则必须提供自定义的删除器。如果没有提供,则shared_ptr会默认调用delete,行为未定义。

1
2
shared_ptr<int> sp(new int[10], [](int *p) { delete [] p; });
*(sp.get() + 5) = 2; // shared_ptr未定义下标运算符,且智能指针不支持指针算术运算

allocator

1
2
3
4
5
6
7
8
9
10
11
class C {
public:
C(int a, int b) : a_(a), b_(b) {}

private:
int a_;
int b_;
};

C *const pc = new C[10]; // 错误
C *const pc1 = new C[5]{1, 2, 3, 4, 5}; // 错误

new把内存分配和对象构造组合在了一起,可能造成外的开销;同时若类没有默认构造函数,则不能够分配动态数组。

allocator分离内存分配和对象构造,避免不必要的开销。所分配的内存是原始的,未构造的。

1
2
3
4
5
6
7
8
9
10
11
12
13
allocator<C>::size_type n = 10;
allocator<C> alloc;
auto const p = alloc.allocate(n); // C *const p1 = alloc.allocate(10);
auto q = p;
alloc.construct(q++, 1, 2); // 类似make_shared
cout << p->a_ << " " << p->b_ << endl;
cout << q->a_ << " " << q->b_ << endl; // 错误,q指向的内存未构造

while (q != p) {
alloc.destroy(--q);
}

alloc.deallocate(p, n); // 大小应和allocate时的一样

拷贝和填充未初始化内存

下列操作所需的内存是由allocate分配的,而不是系统分配的,因此alloc_b指向的内存必须足够大。

1
2
3
4
uninitialized_copy(b, e, alloc_b); // 返回最后一个构造的元素之后的位置
uninitialized_copy_n(b, n, alloc_b); // 返回最后一个构造的元素之后的位置
uninitialized_fill(alloc_b, alloc_e, t);
uninitialized_fill_n(alloc_b, n, t);

关联容器支持普通容器操作,不支持,

  • 顺序容器位置相关的操作,push_back等;
  • 构造函数或插入操作接受一个元素值和一个数量值得操作。

定义

定义一个map时必须指定关键字类型和值类型,set只需关键字类型

初始化

可以用下面的方式来初始化,

  • 同类型容器的copy;
  • 指定值范围(begin和end);
  • 列表初始化。

有序关联容器关键字类型的要求

有序关联容器关键字类型必须定义元素比较的方法。默认情况下,使用<进行比较。

1
2
3
4
// list的iterator并无<
map<list<int>::iterator, int> ml; // 声明不会报错
list<int> li;
ml[li.begin()] = 0; // 报错

对于有序容器,有序容器的关键字必须是严格弱序的,可看做“小于等于”。

自定义比较操作

用于组织一个容器中元素的操作的类型也是容器类型的一部分,如果需要自定义操作,则在定义容器的时候就指明。

1
2
boo compare(...) { ... }
multiset<Sales_sata, decltype(compare) *> bookstore(compare);

创建对象时,提供的操作类型(函数指针)必须与尖括号中的类型吻合。规则与函数的const形参和实参的规则一致,忽略top const。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto comp = [](int a, int b) { return false; };
multiset<int, bool (*)(const int, const int)> m(comp);

auto comp1 = [](const int a, const int b) { return false; };
multiset<int, bool (*)(int, int)> m1(comp1);

auto comp2 = [](int* const a, int* const b) { return false; };
multiset<int, bool (*)(int*, int*)> m2(comp2);

auto comp3 = [](int* a, int* b) { return false; };
multiset<int, bool (*)(int* const, int* const)> m3(comp3);

auto comp4 = [](int* a, int* b) { return false; };
multiset<int, bool (*)(const int*, const int*)> m4(comp4); // 错误

pair

模板,接受两个类型名,pair的数据成员将有对应的类型,两个类型不要求一样。

创建对象时,pair的默认构造函数对数据成员进行值初始化(vector也可以)。

1
pair<string, size_t> word_count;

若函数返回pair,可对返回值进行列表初始化,不必显式构造返回值。

关联容器的操作

map中,每个元素就是一个pair对象,由于关键字不可变,因此pair的关键字部分是const。set的关键字也是const

1
2
3
map<string, int>::value_type v1; // pair<const string, int>
map<string, int>::key_type v2; // string
map<string, int>::map_type v3; // int

关联容器的迭代器

对关联容器迭代器解引用,可得到容器的value_type的引用。

对于set,虽然set定义了iteratorconst_iterator,但是都不能改变set中的元素。

当迭代器遍历一个map,multimap,set或multiset时,按关键字升序遍历。

关联容器和算法

由于关键字是const,因此不能用于修改或重排容器的算法(都需要向元素写入值)。只可用于读取元素的算法。

添加元素

insertemplace可以对关联容器进行插入,

1
2
3
4
5
6
7
8
9
10
11
c.insert(v)
c.emplace(args)
// map和set返回pair<iterator, bool>,iterator指向有此关键字的元素,bool说明是否元素是否已经存在,即是否插入成功。multimap和multiset总是进插入,只返回bool。

c.insert(b, e)
c.insert(li)
// 返回void

c.insert(p, v)
c.emplace(p, args)
// p指明了从哪里开始新元素的存储位置

删除元素

关联容器有三个版本的erase,

1
2
3
4
5
6
7
8
c.erase(k)
// 删除所有key为k的元素,返回size_type,表明删除的数量

c.erase(p)
// 返回被删除元素后的迭代器

c.erase(b, e)
// 返回e

map的下标

由于set并无关联值,下标操作对set无意义,故set不支持。multimap和multiset可能存在多个与某个key关联的值,故也不支持。

下标操作返回mapped_type,是左值。如果关键字不在map中,下标操作会,

  1. 创建一个元素并插入,关联值将进行值初始化
  2. 提取元素并赋值。

注意,

  1. 与vector和string不同,map的下标操作和解引用返回的类型(mapped_typevalue_type)不一样;
  2. 如果元素不存在,at并不会创建,而是抛出out_of_range
  3. 下标操作可能会改变map,对const的map无法使用;
  4. 对于const的map,只要at不修改元素,就可以用。
    1
    2
    3
    4
    const map<string, int> m{{"hello", 1}};
    cout << m.at("hello") << endl;
    cout << m["hello"] << endl; // 错误,即使是普通访问
    m.at("hello") = 1; // 错误

无序关联容器

使用hash function和==来组织元素,用hash<key_type>类型的对象生成每个元素的hash值,有序关联容器的操作可以用于无序容器。

标准库为内置类型(包括指针类型)和部分标准库类型(包括string和智能指针)类型定义了hash。但不能直接把自定义类型作为key来定义无序容器,可以

  1. 提供自己的hash模板版本;
  2. 定义hash function和==运算符。
1
2
3
4
5
6
7
8
9
size_t hasher(const Sales_data& sd) {
return hash<string>() (sd.isbn());
}

bool eqOp(const Sales_data& lhs, const Sales_data&rhs) {
return lhs.isbn() == rhs.isbn();
}

unordered_multiset<Sales_data, decltype(hasher)*, decltype(eqOp)*> set(42, hasher, eqOp);

首先介绍顺序容器操作基本相同的部分,然后分别是每种容器要注意的地方。

定义和初始化

  • C c
    默认构造函数
  • C c1(c2)C c1 = c2
    c1初始化为c2的copy。c1和c2必须是相同的容器类型,且保存相同类型的元素。
  • C c{a, b, c, ...}C c = {a, b, c, ...}
    列表初始化
  • C c(b, e)

除array外,还有以下两种,

  • C seq(n)
  • C seq(n, t)

C c1(c2)C c(b, e)

C c1(c2)要求两个容器必须是相同的类型,且元素类型也是相同的;但C c(b, e)就只需要元素类型可以转换为要初始化的容器的元素类型即可。

赋值和swap

所有容器都支持赋值=,赋值后,左边容器的元素为右边容器元素的copy,且大小与右边容器相同。

swap

  • swap会交换两个容器的元素,两个容器必须有相同的类型;
  • 通常来说swap只是交换了容器内部的数据结构,但也有例外,对array进行操作时,会真正交换它们的元素;
  • swap完成以后,除string外,指向容器的迭代器、引用和指针都不会失效。

assign

  • seq.assign(b, e)
  • seq.assign(il)
  • seq.assign(n, t)

要注意的有,

  • 参数非常像初始化的;
  • assign不适用于关联容器的array;
  • 可以从一个不相同但相容的类型assign。

大小

除了forward_list,每个容器都支持size()empty()max_size();forward_list只支持empty()max_size()

运算符

每种容器都支持==!=

除了无序关联容器,所有容器都支持>>=<<=

操作

push…和insert

push_backinsertpush_front放入容器的是元素的copy。

emplace…

emplace_frontemplaceemplace_back分别对应push_backinsertpush_front。不支持push…或insert的容器,也不支持相应的emplace

push_backpush_frontinsert放入元素的copy不同,emplace会见参数传递给元素类型的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class C2 {
public:
C2() = default;
explicit C2(int a) : a_(a) {}
C2(int a, int b) : a_(a), b_(b) {}

private:
int a_ = 0;
int b_ = 0;
};

vector<C2> cs2;
// 由于是explicit,不存在从int到C2的隐式转换
// cs2.push_back(1);
// cs2.push_back(); // 错误
// cs2.push_back(1, 2); // 错误

cs2.push_back(C2());
cs2.push_back(C2(1));
cs2.push_back(C2(1, 2));

// 调用了C2的构造函数,对应以三个push_back
cs2.emplace_back();
cs2.emplace_back(1);
cs2.emplace_back(1, 2);

emplace在容器中直接构造元素,由于参数是传递给元素的构造函数,因此实参的类型必须和构造函数匹配。

访问元素

可用以下方法访问顺序容器的元素:

  • c.back()
  • c.front()
  • c[n]
  • c.at(n)

下标操作和at实际上就是进行随机访问,因而只能用于支持随机访问的顺序容器(string、vector、deque和array)。两个随机访问中,只有at能够保证安全的随机访问,下标越界时,会抛出out_of_range异常。

删除元素

  • c.pop_back()
  • c.pop_front()
  • c.erase(p)
  • c.erase(b, e)
  • c.clear()

pop_backpop_front返回voiderase返回一个迭代器,位置为最后一个被删除元素的下一个。

如果be相等(即使都为c.end()),那么不会删除任何元素。

容器适配器

适配器是一种机制,使得某种事物的行为看起来像另外一个事物。容器、迭代器和函数都有适配器。

每个适配器有两个构造函数,

  • A a
  • A a(c),拷贝容器c来初始化a

顺序容器适配器有,

  • stack,默认情况下基于queue实现;
  • queue,默认情况下基于queue实现;
  • priority_queue,默认情况下基于vector实现;

一般来说,用于构造适配器的容器是有限制的,

  • 能添加和删除元素;
  • 访问尾元素;

具体到每个适配器,

  • stack
    push_backpop_backback
  • queue
    push_backpush_frontbackfront
  • priority_queue
    frontpush_backpop_back,随机访问

vector

capacity和size

vector支持快速随机访问,元素是连续存储的。这意味着添加元素时,需要移动已有的元素,以保证连续的存储。如果没有空间容纳新元素,则需要分配新的内存,并把已有元素从旧的位置移动到新的位置。为了减少内存的分配和释放的代价,分配策略一般为实在是没法存时,才获取新的内存。只要没有操作使得vector的capacity不够,就不会重新分配内存。

以下几个操作是管理容器大小的,

  • c.shrink_to_fit()
    请求把capacity减少为size。这里仅仅是一个请求,shrink_to_fit不保证退还内存。
  • c.capacity()
    在不重新分配内存的情况下,最多能存储的元素数目。
  • c.reserve(n)
    分配至少能容纳n个元素的内存。n如果小于等于当前capacity,那么什么都不发生。

要注意的是,reserve和resize不同,reserve改变(至少是变大)的是capacity,size并未变化;而resize改变了size的同时,capacity(如果n大于当前的capacity)也有可能改变。

1
2
3
4
5
6
void t23() {
vector<int> v1(10, 10); // size: 10 capacity: 10
vector<int> v2(10, 10); // size: 10 capacity: 10
v1.resize(15); // size: 15 capacity: 20
v2.reserve(15); // size: 10 capacity: 15
}

迭代器

添加元素的情况下,

  • 如果添加前capacit = size,那么会导致内存重新分配,从而vector相关的迭代器、引用或指针都会失效;
    如果不发生内存分配,有可能会发生元素移动(插入中间位置),插入位置之后的迭代器、引用或指针都会失效。

对于删除元素,不会发生内存重新分配,被删除元素前的迭代器、引用或指针都还有效。

deque

迭代器

添加元素的情况下,
如果插入到首尾之外的位置,都会导致迭代器、引用或指针失效;

删除元素的情况下,

  • 如果在首尾之外的位置删除,都会导致迭代器、引用或指针失效;
  • 如果删除了尾元素尾后迭代器也会失效,但其他的迭代器、引用或指针不受影响。

string

操作

string也支持上述提及的大部分操作,但同样有例外,且某些操作有特殊局限,

  • swap会导致相关的迭代器、引用或指针失效;
  • 与vector一样,front相关的操作不支持;
    • 不支持push_frontemplace_front
    • 不支持pop_front;
  • inserteraseassignreplace重载函数,p323和p324;
    • 额外的inserteraseassign
    • append在末尾插入;
    • replace,等价于erase+insert
  • 搜索,p325和p326;
    • 返回值均为string::size_type(),是一个unsigned类型,如果找不到,这返回string::npos
    • s.find(args),返回第一个匹配args的下标;
    • s.rfind(args),返回最后一个匹配args的下标;
    • s.find_first_of(args),返回args中任何一个字符首次出现在args中的下标;
    • s.find_last_of(args)
    • s.find_first_not_of(args),返回首个不在args中字符的下标;
    • s.find_last_not_of()
  • compare同样有多个重载,p327;
  • 数值转换,p328;
    • to_string(val)
    • sto...
      • string中的第一个非空白字符必须是数值中可能出现的字符,即+或-,或数字,数字可以是0x或0X开头表示的十六进制数;
      • 如果是转换为浮点数的,开头可以为.,且包含e或E表示指数;
      • 如果是转换为整型的,根据不同的基数,可以有字母;
      • 如果不能转换,则抛出invallid_argument
      • 如果得到的数值无法用任何类型表示,则抛出out_of_range

泛型算法

  • 一般泛型算法不直接操作容器,而是运行于迭代器之上,由迭代器来进行操作;
  • 迭代器令算法不依赖于容器,但是某些算法使用的操作需要元素的类型;
  • 算法不会直接改变底层容器的大小;
  • 算法可能改变元素的值或移动元素,但不会直接添加或删除元素。

按使用元素的方式分类

只读算法

accumulate

accumulate的第三个参数类型决定了函数中使用那个加法运算符以及返回值的类型。

  • 如果这个类型不支持+运算符,则会发生编译错误;
  • 如果元素类型与这个类型不匹配,且能够类型转换,无论哪个类型宽窄,只会发生元素类型->第三个参数类型

equal

按元素比较,第二个序列至少与第一个序列一样长

如果两个序列类型分别为,vector<const char*>list<const char*>,则比较的是地址,不是字符串的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
vector<const char *> vcc{"ab", "bc", "cd"};
list<const char *> lcc{"ab", "bc", "cd"};

// 由于编译器优化,vcc和lcc实际上共享了字面值常量,故下面的地址相同。
cout << equal(vcc.begin(), vcc.end(), lcc.begin()) << endl;
// 1
cout << static_cast<void *>(const_cast<char *>(vcc.front())) << endl;
// 0x40c951
cout << static_cast<void *>(const_cast<char *>(lcc.front())) << endl;
// 0x40c951

const char a[3][3] = {"ab", "bc", "cd"};
const char b[3][3] = {"ab", "bc", "cd"};
vector<const char *> vcc1{begin(a), end(a)};
list<const char *> lcc1{begin(b), end(b)};

cout << equal(vcc1.begin(), vcc1.end(), lcc1.begin()) << endl;
// 0
cout << static_cast<void *>(const_cast<char *>(vcc1.front())) << endl;
// 0x40c908
cout << static_cast<void *>(const_cast<char *>(lcc1.front())) << endl;
// 0x40c911

写容器元素的算法

这类算法并不检查写操作。由于算法不会改变底层容器的大小,因此必须保证目的位置迭代器开始序列足够容纳要写入的元素。

copy返回的是目的位置迭代器递增后的值。

重排容器元素的算法

unique“移除”了相邻重复元素,把不重复的元素移动到了序列前面,并非删除。返回不重复元素范围末尾的下一个迭代器。

定制操作

谓词

某些算法需要进行元素间的比较,如果需要使用与定义行为不同的比较,或者元素类型未定义<运算符,则需要通过提供谓词,重载算法的默认行为。

谓词是可调用的表达式,返回结果是一个能用着条件的值,分为,

  • 一元谓词;
  • 二元谓词。

序列中的元素作为实参传入谓词,因此需要满足函数匹配规则。

lambda表达式

是可调用对象,callable object有,

  • 函数
  • 函数指针
  • 重载了函数调用运算符的类
  • lambda表达式
  • bind创建的对象

使用lambda,要注意的是,

  • lambda必须使用尾置返回,可以忽略参数列表和返回类型(忽略时,从代码中推断);

    1
    auto f = []{ return 1; };
  • lambda不能有默认参数;

  • 对于lambda所在函数体的非static局部变量,只能使用在捕获列表中捕获后,才能使用;

  • 对于局部static变量和lambda所在函数体之外声明的名字,可以直接使用

如果lambda捕获列表为空,那么lambda可以转换为函数指针。

1
2
3
4
auto f = [](int a, int b) { return a + b; };
// f自动转换为pointer
int (*pf)(int, int) = f;
cout << pf(1, 2) << endl;

lambda捕获和返回

定义lambda时,编译器生成一个与lambda对应的未命名类类型。用auto定义一个用lambda初始化的变量时,就定义了一个相应的未命名类类型的对象。lambda的数据成员在lambda对象创建时被初始化。

类似函数的参数传递,捕获方式有,

每种方式都可以进行,

  • 值捕获

    被捕或的变量的值是在lambda创建时拷贝,而不是像函数调用时才拷贝。能使用值捕获的前提是变量可拷贝

  • 引用捕获

    &表示以引用的方式捕获。
    引用捕获与返回引用有相同的问题和限制,必须保证引用的对象在lambda执行时存在,且在执行时是所期望的(可能在被捕或后和执行前,引用的对象的值改变了)。如果函数返回lambda,则不能包含局部非static变量的捕获。

按是否显示列出希望使用的变量,可分为,

  • 显式捕获

    [v1, ...][&v1, ...]

  • 隐式捕获

    • 值捕获,[&]
    • 引用捕获,[=]
  • 混合使用显式和隐式捕获

    当混合使用显式和隐式捕获时,显示捕获的变量必须使用与隐式捕获不同的方式。

    • [&, identifier_list]

      任何隐式捕获的变量都采用引用捕获identifier_list采用值捕获的方式,且identifier_list中的名字不能使用=

    • [=, identifier_list]

      任何隐式捕获的变量都采用值捕获identifier_list采用引用捕获的方式,且identifier_list中的名字必须使用&

      1
      2
      3
      4
      5
      6
      int a = 1;
      int b = 2;
      int c = 3;

      auto l = [=, &c](){};
      // auto l = [=, c](){}; 错误

可变lambda

默认情况下,值捕获的变量,lambda不会改变被捕获变量的值(并非改变原始变量),如果需要改变,加入mutable

1
2
3
4
5
6
7
8
int a = 1;

auto l2 = [a]() mutable {
++a; // 如果无mutable,则错误
return a;
};
cout << a << endl; // 1, 并非改变原始变量
cout << l2() << endl; // 2

引用捕获无此限制,能够更改依赖于被引用变量是否为const

lambda的返回值

默认情况下,如果一个lambda包含return之外的任何语句,编译器假定此lambda返回void。

bind

由于find_if接受的是一个一元谓词,因此含有两个形参的函数是不可用的。

bind生成一个新的可调用对象,可看做一个通用的函数适配器。

1
auto newCallable = bind(callable, arg_list)

arg_list可包含_n,表示newCallable的参数,参数的类型就是callable中_n处的类型。其中n代表了占位符_nnewCallable的位置,调用newCallable时,_n处的参数最终会传递到callable中_n相应的位置。

_n定义在placeholders的namespace中,使用时需,

1
2
3
4
using std::placeholders::_1;
...
// 或
using namespace std::placeholders;

默认情况下,不是占位符的参数是被拷贝到bind返回的可调用对象中的。如果想传递引用,就必须显式的指明,使用refref返回的对象包含给定的引用,是可拷贝的。类似的还有cref,返回const引用。

额外的迭代器

除每个容器的定义的迭代器外,还有以下几种,

插入迭代器

是一种迭代器适配器,对插入迭代器赋值时,该迭代器调用容器的操作来进行插入。*和前置后置++会直接返回迭代器,并不会修改

插入迭代器有以下几种,

  • back_inserter

    创建使用push_back的迭代器,始终在尾部插入。

  • front_inserter

    创建使用push_front的迭代器,始终在首部插入。

  • inserter

    创建使用insert的迭代器,始终在迭代器it指定位置前插入。插入后it还是指向一开始指定的位置。

流迭代器

istream_iterator

读取输入流,必须指定迭代器将要读写的对象类型,且要读取的类型必须支持>>(由于是调用>>来读取)。

1
2
3
4
5
6
7
8
istream_iterator<int> int_it(cin);
istream_iterator<int> int_eof; // 默认初始化,尾后迭代器

while (int_it != eof) {
...
}

vector<int> v(int_it, eof);

当绑定到流时,标准库并不保证迭代器立即从流中读取数据,而是保证在首次解引用前,完成了数据的读取

ostream_iterator

类似istream_iterator,使用<<写入输出流(类型必须支持<<)。必须绑定到一个输入流,可指定一个每次写入时都输出的字符。*和前置后置++会直接返回迭代器,并不会修改

1
2
3
4
ostream_iterator<int> out(cout);
// ostream_iterator<int> out(cout, " "); 字面值常量或指向C风格的字符串

out = 5; // 类型必须与out定义的类型兼容

反向迭代器

也是一种迭代器适配器。只能从一个支持++和–的迭代器来定义反向迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
    cbegin()                                     cend()
| |
[], [], [], [], [], [], [], [], [], [], [], []
| |
crend() crbegin()

cbegin() rit.base() cend()
| | |
[], [], [], [], [], [], [], [], [], [], [], []
| |
rit crbegin()

[crbegin(), rit)和[rit.base(), cend())指向相同的范围。

可以对反向迭代器调用base()来得到普通的迭代器。base()得到的是相邻的位置。

移动迭代器

一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器。对移动迭代器解引用生成的是一个右值

1
auto newe = uninitialized_copy(make_move_iterator(elements), make_move_iterator(cap), newb);

通过调用make_move_iterator可将一个普通迭代器转换为一个移动迭代器。上面的代码中,传递给uninitialized_copy的是一个移动迭代器,解引用后得到的是右值,因此uninitialized_copy将使用移动构造函数来构造元素。

泛型算法的结构

迭代器分类

按算法所要求的迭代器操作,可将迭代器分为下面几类,除了输出迭代器外,高层类别的迭代器支持低层类别迭代器的所有操作。C++标准指明了泛型和数值算法的每个迭代器参数的最小类别

  1. 输入迭代器

    只读,不写;单遍扫描,只能递增

    支持,

    • ==!=

    • 前置后置++,可能导致所有其他指向流的迭代器失效,不能保证输入迭代器的状态可以保存下来,即只能单遍扫描

    • *,只能在赋值运算的右侧

    • ->

      例子:findaccumulate

  2. 输出迭代器

    只写,不读;单遍扫描,只能递增

    支持,

    • 前置后置++

    • *,只能在赋值运算的左侧

      例子:用作目的位置的迭代器,如copy

  3. 前向迭代器

    可读写;多遍扫描,只能递增

    支持,

    • 所有输入和输出迭代器的操作

      例子:replaceforward_list的迭代器。

  4. 双向迭代器

    可读写;多遍扫描,可递增递减

    支持,

    • 所有前向迭代器的操作

    • 前置后置--

      例子:reverse,除了forward_list的迭代器,其他标准库容器的迭代器都符合双向迭代器。

  5. 随机访问迭代器

    可读写;多遍扫描,支持全部迭代器的运算

    支持,

    • <<=>>=

    • 和整数的+-+=-=

    • 两个迭代器的-

    • iter[n],等价于*(iter[n])

      例子:sortarraydequestringvector的迭代器。

算法形参模式

  • alg(beg, end, other, args);
  • alg(beg, end, dest, other, args);
  • alg(beg, end, beg2, other, args);
  • alg(beg, end, beg2, end2, other, args);

向输出迭代器dest写入数据的算法都假设,目标位置有足够的空间。
接受单独beg2的算法假定从beg2开始的序列与[beg, end)所表示的范围至少一样大。