标准容器
C++容器分类:顺序容器,有序容器,无序容器。所有的容器都有一个基本的特性:容器保存元素采用的是值语义,也就是说,里面存储的是元素的副本、复件,而不是引用。
从这个基本的特性可以得出一个推论,容器操作元素的很大一块成本就是值的复制。所以,如果元素比较大, 或者非常多,那么操作时的复制成本就会很高,性能也就不会太好。
一种解决办法就是尽量为元素实现转移构造函数和转移赋值函数,在加入容器的时候使用std::move来转移元素,降低元素复制的成本。例如:
Point p; //复制成本很高的对象
v.push_back(p); //存储对象,复制构造,成本很高
v.push_back(std::move(p)); //定义转移构造函数后可对元素转移,降低成本
C++11之后新增加的emplace()函数操作,他可以就地构造元素,免去了构造再复制、转移的成本,不但高效,还方便。
v.emplace_back(..); //直接在容器中构造元素,不需要复制或者移动
顺序容器
顺序容器就是数据结构里的线性表,一共5种:array/vector/deque/list/forward_list
这5种容器又可以按照内部的存储结构细分为两组。
- 连续存储的数组:array/vector/deque
- 指针结构的链表:list/forward_list
array/vector直接对应C的内置数组,内存布局与C完全兼容,所以是成本最低,速度最快的容器。
它们的区别在于容量能否动态增长:array是静态数组,大小在初始化的时候就固定了,不能再容纳更多的元素,而vector是动态数组,虽然初始化的时候设定了大小,但可以在后续运行时按需增长,容纳任意数量的元素。
array<int, 2> arr; //初始化一个静态、数组,长度是2
assert(arr.size() == 2); //静态数组的长度总是2
vector<int> v(2); //初始化一个动态数组,长度是2
for(int i = 0; i < 10; i++){
v.emplace_back(i); //追加多个元素
}
assert(v.size() == 12); //长度动态增长到12
deque也是一种可以动态增长的数组,它与vector的区别是它可以在两端高效地插入和删除元素,这也是它的名字"double-end queue"的来历,而vector则只能用push_back在末端追加元素。
deque<int> d; //初始化一个动态数组,长度是0
d.emplace_back(9); //在后端添加一个元素
d.emplace_front(1); //在前端添加一个元素
assert(d.size() == 2); //长度动态增长到2
vector/deque里的元素因为是连续的,所以在中间插入和删除效率很低,而list/forward_list因为是链表结构,插入和删除只需要调整指针,所以在任意位置操作都很高效。
链表结构的缺点是查找效率低,只能沿着指针顺序访问,这方面不如vector随机访问的效率高,list是双向链表,forward_list是单向链表。
当vector的容量达到上限的时候,通常会再分配一块两倍大小的新内存,然后把旧元素复制或者移动过去,这个操作成本非常大,所以在使用vector的时候最好能够预估容量,使用reserve()提前分配足够的空间,降低动态扩容的复制成本。deque/list的扩容策略保守,只会按照固定的步长去增加容量,但短时间内插入大量数据的时候,这种做法就会频繁分配内存,效果反而不如vector一次分配。
首选容器array/vector,它们速度最快、成本最低,数组的形式也令它们最容易使用,搭配算法也可以实现快速排序和查找。deque/list/forward_list适合对插入和删除性能比较敏感的场合,如果还很在意空间成本,那就只能选择非链表deque.
有序容器
有序容器,它的元素在插入容器后就被按照某种规则重新调整次序,所以是有序的。
C++的有序容器使用的是树结构,通常是红黑树——有着最好查找性能的二叉树。
标准库一共有4种有序容器:set/multiset和map/multimap,set是集合,map是关联数组,在其他变成语言里也叫字典。有multi前缀的容器表示可以容纳重复的key,而内部结构与无前缀的相同。
在定义容器时候必须要指定key的比较函数,只不过这个函数通常默认为less(),表示小于关系,不用特意写出来。
解决自定义类型作为容器的key,一是重载比较操作符"<",另一个是自定义模板参数。
//重载比较操作符"<"
bool operaotr<(const Point& a, const Point& b)
{
return a.x < b.x; //自定义比较运算符
}
set<Point> s; //现在可以正常放入容器了
s.emplace(7);
s.emplace(3);
//编写专门的函数对象或者lambda表达式,然后在容器的模板参数里将其指定为比较函数
set<int> s = {7, 3, 9}; //定义集合并初始化3个元素
for(auto &x: s){ //循环输出元素
cout << x << ","; //从小到大排序3,7,9
}
auto comp = [](auto&& a, auto&& b){ //定义一个lambda表达式,用来比较大小
return a>b; //定义大于关系
}
set<int, decltype(comp)> gs(comp); //使用decltype得到lambda表达式的类型
std::copy(begin(s), end(s), inserter(gs, gs.end())); //复制算法,复制到另一个容器(使用迭代器插入)
for(auto& x : gs){
cout << x << ","; //从大到小排序9,7,3
}
如果需要实时插入排序,则选择map/set,如果不需要实时插入排序,那么最好还是用vector,全部插完后再一次性排序。
无序容器
无序容器有4种:unordered_set/unordered_multiset/unordered_map/unordered_multimap
无序容器的使用和有序容器一样,区别仅限于内部的数据结构:它不是红黑树。而是散列表,也叫哈希表。因为它采用散列表,元素位置取决于计算的散列值,没有规律可言,所以是无序的,我们可以把它理解为乱序容器。
using map_type = //类型别名
unordered_map<int, string>; //使用无序关联数组
map_type dict; //定义一个无序关联数组
dict[1] = "one"; //添加3个元素
dict.emplace(2, "two");
dict[10] = "ten";
for(auto &x: dict){ //遍历输出
cout << x.first << "=>" //顺序不确定
<< x.second << ","; //既不是插入顺序,也不是大小顺序
}
选择:只想单纯的set/map。没有排序需求,就应该使用无序容器,没有升序比较的成本,它的速度会非常快。
无序容器要求key具备两个条件,一是可以计算散列表,二是可以执行相等比较操作。第一个是因为散列表的要求,只有计算散列值才能放入散列表;第二个是因为散列值可能会冲突,所以当散列值相同时,就要比较真正的key值。
与有序容器类似,要把自定义类型作为Key放入无序容器就必须要实现以下两个函数。
相等函数,使用重载实现
bool operator == (const Point& a, const Point& b)
{
return a.x == b.x;
}
散列函数,可以使用函数对象或者lambda表达式实现,在内部最好调用标准的散列函数对象,而不要自己直接计算,否则容易造成散列值冲突。
auto hasher = [](const auto& p){
return std::hash<int>()(p.x); //调用标准散列函数对象计算
};
unordered_set<Point, decltype(hasher)> s(10, hasher);
s.emplace(7);
s.emplace(3);
特殊容器
可选值(optional)
C++提供的函数只能返回一个值,这在需要表示无效概念的时候就会比较麻烦,无效值通常是一个特殊0或者-1,例如分配内存返回nullptr,查找字符返回npos.
C++模板类optional,可以近似地看作只能容纳一个元素的特殊容器,通过判断容器是否为空来检查有效或者无效,因此可以调用成员函数has_value(),示例代码:
optional<int> op; //持有Int的optional对象
assert(!op.has_value()); //默认是无效值
op = 10; //赋值,持有有效值
if(!op.has_value()){
cout << op.value() << endl; //获取值的引用
}
optional<int> op2; //初始化无效optional
cout << op2.value_or(99) << endl; //无效,返回给定的替代值
另一方面,optional的行为又很像指针,可以用"*","->"来直接访问内部的值,也能够显式的转换为bool值,或用reset()清空内容,用起来像unique_ptr:
optional<string> op {"zelda"}; //持有string的optional对象
assert(op); //可以像指针一样用于bool型逻辑判断
assert(!op->empty() && *op == "zelda"); //使用* -> 访问内部值
op.reset(); //清空内部值
assert(!op); //此时是无效值
同样optional也可以用于工厂函数make_optional()来创建,不过与直接构造不同,即使不提供参数,工厂函数必定会创建出一个持有有效值的optional对象,例如:
auto op1 = make_optional<int>(); //使用默认值构造有效值
auto op2 = make_optional<string>();
当函数需要返回可能无效的值,只需要把返回值用optional包装就可以。
auto safe_sqrt = [](double x){
optional<double> v; //默认无效
if(x < 0){
return v;
}
v = ::sqrt(x); //值有效
return v;
};
assert(!safe_sqrt(-1)); //负数无法求平方根
assert(safe_sqrt(9).value() == 3); //正数平方根
optional最后注意:当它内部持有bool 类型的optional对象时,由于它本身可以被转换成bool类型,但这个bool类型表示的是optional的有效性,而非内部的bool真假,就必须判断两次
optional<bool> op {false}; //持有bool类型的optional对象
if(op){ //错误用法,实际判断的是有效性
cout << "misuse" << endl;
}
if(op && op.value()){ //正确,有效后再检查值
cout << "right" << endl;
}
可变值
C++17引入了一个新模板类。variant,它可以说是一个智能union,可以聚合任意类型,同时用起来又和union几乎一样了。它像只能容纳一个元素的"异质"容器,想知道当前哪种元素可以调用成员函数index():
variant<int, float, double> v; //可以容纳3种不同的整数
v = 42;
assert(v.index() == 0); //索引是0
v = 3.14f; //直接赋值float
assert(v.index() == 1); //索引是1
v = 2.718;
assert(v.index() == 2); //索引是2
variant不能用成员变量的形式来访问内部的值,必须用外部的模板函数get()来获取值,模板参数可以是类型名或者是索引。
如果用get()访问了不存在的值就会出错,以抛出异常的方式告知用户。
v = 42;
assert(get<0>(v) == 42); //取索引值为0的值,即int
v = 2.718;
auto x = get<double>(v); //取double得值,即索引为2
get<int>(v); //当前是double,所以出错,抛出异常
抛出异常不太友好,使用另外一个函数get_if(),它以指针的方式返回variant内部的值,如果内部值不存在,返回空指针。
auto p = get_if<int>(&v); //取int得值,不存在就返回空指针
assert(p == nullptr);
函数visit()提供了get()/get_if()之外的另一种更灵活的使用方式,不需要考虑类型的索引,而是一个集中业务逻辑的访问器函数来专门处理variant对象。
这个访问器最好是泛型的lambda表达式,写起来更方便;
variant<int, string> v; //可以容纳int和String
auto vistor = [](auto &x){
x = x + x;
cout << x << endl;
}
v = 10;
std::visit(vistor, v); //输出20
v = "ok";
std::visit(vistor, v); //输出"okok"
实现访问器函数的时候,它必须能处理variant的任何可能的类型,否则无法编译通过。如果我们将lambda里的赋值语句改写为 "x = x * x",那么它肯定是无法应用于string.
variant异质容器的特性非常有价值,它可以完全在不使用继承、虚函数的情况下实现面向对象里的多态特性,因为没有虚表指针,它的运行效率会更高。
struct Swan final{ //天鹅类,可以飞 。使用struct方便不写public
void fly(){
cout << "swan files" << endl;
}
};
struct Ostrich final{ //鸵鸟类,不可以飞
void fly(){
cout << "Ostrich cant fly" << endl;
}
};
struct Phoenix final{ //凤凰类
void fly(){
cout << "Phoenix fly high" << endl;
}
};
//定义能容纳它们的variant类型
variant<Swan, Ostrich, Phoenix> bird;
auto fly_it = [](auto & x){ //泛型lambda表达式
x.fly();
}
bird = Swan(); //天鹅对象
std::visit(fly_it, bird);
bird = Ostrich(); //鸵鸟对象
std::visit(fly_it, bird);
任意值
any,能够在运行时任意改变类型,是无界的。
any a;
a = 10;
a = 0.618;
a = "hello any"s;
a = vector<int>(10);
any基本用法与optional有点像,可以用has_value()检查是否有值,可以用reset清空内容,也可以调用工厂函数make_any()存入值。
auto a = make_any<long>(99); //调用工厂函数存入整数
assert(a.has_value()); //检查是否有值
a.reset(); //清空存储值
assert(!a.has_value()); //检查是否有值
any的目标不是标记值得有效性,它不提供类似指针的操作,而且接口比variant的还少,没有get()/get_if(),只能用模板函数any_cast()指定类型来进行转换得到内部值,用法上和传统的转型操作符static_cast/dynamic_cast很像。不过在进行类型转换的时候,我们应当尽量用引用或指针的形式,否则它会创建出一个临时对象,带来额外的成本。
a = 100L; //存储整数
assert(any_cast<long>(a) == 100); //转型成长类型,有临时成本
auto &v = any_cast<long&>(a); //转型成引用,推荐使用
v = 200L; //引用可以直接赋值
assert(any_cast<long>(a) == 200); //转型长整型
a = "any"s; //存入字符串
auto p = any_cast<string>(&a); //转型成指针,推荐使用
assert(p && p->size() == 3);
如果类型不对,转型失败,any_cast()就会抛出异常,如果是指针形式,则会返回空指针,这和variant的get_if()行为有些类似。
any_cast<int>(a); //类型不对,抛出异常
assert(any_cast<int>(&a) == nullptr); //指针转型失败,返回空指针
想要检查any是否持有特定的对象,可以使用成员函数type(),该函数返回std::type_info对象,与其他类型用typeid计算后比较是否相等
a = 10;
assert(a.type() == typeid(int)); //比较整数的typeid
a = "string"s;
assert(a.type() == typeid(string));
//any运行时动态检查
if(a.type() == typeid(long)){
//any为long
}else if(a.type() == typeid(string)){
//any为string
}
另外一种方式是使用带初始化的if,调用any_cast()转换出指针变量,通过空指针来判断类型是否正确:
if(auto p = any_cast<long>(&a); p){ //初始化,转换成指针再判断
cout << *p << endl; //以指针方式使用any变量
}
any没有variant的编译期检查功能。
多元组
C++里的pair类型,它可以持有两个不同类型的值,也就是二元组。而新的数据结构tuple是对pair的进一步增强,能够持有任意多个不同类型的值,他可以被称为“多元组”,或者简称元祖。
tuple的声明和pair差不多,都需要在模板参数列表里声明元素类型,或者使用工厂函数make_tuple().
tuple要用和variant类似的get()函数,可以通过索引或者类型来定位。例如:
tuple<int, double, string> t1{0, 1, "x"}; //三元组
assert(get<0>(t1) == 0); //指定按索引取成员
assert(get<1>(t1) == 1); //指定按索引取成员
assert(get<string>(t1) == "x"); //指定按类型取成员
auto t2 = make_tuple(1L, "string"s);
assert(get<long>(t2) == 1); //指定类型取成员
get<1>(t2) = "hi"; //get()获取的是引用类型
assert(get<1>(t2) == 1); //指定索引取成员
代替struct:
using Student = tuple<int, string, double>; //using定义类型别名
struct Student{ //等价的struct定义
int id;
string name;
double score;
}
tuple另一个常见的用法就是作为函数的返回值,让函数轻松的返回任意多个值:
auto f = [](){ //tuple用法的Lambda
return make_tuple(true, "ok"s); //使用tuple返回多个值
}
为了获取函数返回的tuple内部成员变量,我们可以使用配套的函数tie,还有C++17引入的结构化绑定。
//C++17
auto [flag, msg] = f(); //结构化绑定自动类型推导
assert(flag && msg == "ok"); //得到tuple内部的变量
//C++11
std::tuple<int,std::string> mytuple (10,"message");
int code;
std::string mean;
std::tie(code, mean) = mytuple;
std::cout << "code is " << code << "; mean is " << mean << std::endl;
总结:
- optional主要用来表示值有效或者无效,用法很像智能指针
- variant是一种异质容器,可以在运行时改变类型,实现泛型多态
- any与variant类似,但可以容纳任意类型,在运行时检查类型会更灵活
- tuple可以打包多种不同类型的数据,也可以算是一种异质容器
- optional/variant/any在任何时刻只能持有一个元素,而tuple则可以持有多个元素