跳跃表计算排名的O(logN)实现

转载自
https://yuerblog.cc/2019/02/13/skiplist-rank/

做游戏的一般都有游戏排行榜的需求,意思就是查一下某个uid的积分排名第几。

这种数据结构其实就是redis里的zset实现,底层其实涉及2个关键数据结构:

  • 哈希表:维护uid -> score的映射关系
  • 跳表:维护score的从小到的有序关系

只要我们先从哈希表找到score,再去跳表里获取这个score的排名(rank)即可。

跳表与二叉树

为什么跳表可以高效的获取rank呢?只能说跳表的数据结构设计巧妙。

跳表本身提供的功能类似于平衡二叉树以及高级变种,可以对目标值进行快速查找,时间复杂度为O(lgN)。但是跳表的实现原理比实现一颗高效的平衡二叉树(比如红黑树)要简单太多,这是跳表非常大的一个优势。

关键在于,跳表计算某个score的排名次序,与在跳表中找到这个score的时间复杂度是一样的,仍旧是O(lgN)。反观二叉树系列,它们找到一个值也很快,但是要想知道这个值排名第几,似乎只能按照先序遍历的方式来统计排在前面的值个数。

其实跳表获取排名的思路也是数一下前面有多少个值,但因为”跳跃”的关系,统计的过程被加速了,因而rank效率更高。

跳表find原理

因为rank的计算过程,实际是伴随find某个score同时进行的,所以首先得知道find是如何进行的。

跳表本质上就是多层索引的链表,上述图中最下面一排是level1索引,串联了跳表中所有节点:5,11,20,30,68,99,跳表数据结构保证了插入位置有序。

每个节点的高度是随机确定的,所以有的节点可以串联到level2或者level3等更高层的链表中。跳表实现确保了,如果节点是高度3,那么会同时串联在level1,level2,level3的链表中。

当然,跳表不是真的随机确定插入的节点高度,而是让高的节点更少,矮的节点更多,最终产生的效果就是上图中的效果,即Level3链表的节点很少,而level2链表的节点多一些,level1链表串联了所有的节点。

当我们要查找数字68的时候,我们会先header节点的最高层链表level3开始向后查找,发现68>20则走到20节点上;发68<99则降低高度到level2;发现68>30则走到30节点上;发现68<99则降低高度到level1;发现68==68,找到了目标节点。

为什么要从最高层链表开始呢?因为高层链表串联的节点之间稀疏,跨度大,所以可以快速推进;一旦发现高层链表没有线索了,则需要下降高度到更稠密的链表索引中,继续向目标推进;直到某一个高度的链表索引中找到了目标;或者到最低层链表也没有找到目标,则说明目标值不存在。相反,如果我们直接从最底层链表向后查找,性能就蜕化为一个普通链表了,当然最终一定能找到目标/找不到目标,但就缺少了”跳表”的机会了。

跳表insert原理

插入和查找过程类似,但需要多做一点事情。

这里是插入数字80,白色是最终插入的位置,蓝色是此前就有的节点。

我们依旧从header节点的level3开始向后推进,每次下降level之前把当前level所处的node记录下来,也就是图中红色圈出来的节点。

然后,我们随机确定了80节点的高度是2,那么接下来各个level的链表该如何建设呢?奇迹就出现了,我们在每一level用红色圈出来的节点,其实就是每一level刚好小于80的那个节点,可以作为80在该level的链表前驱

因为80节点高度定位了2,所以插入到了level1和level2这两层链表,其中level2对整个跳表做出了突出贡献,因为80和30之间跳过了68,可以为之后的目标查找贡献自己的跳板能力。

跳表delete原理

删除一个节点比较简单,其实还是先逐级下降找到目标节点,用红色圈出每一level的前驱。

这里删除80节点:

需要注意,对于每一level中的红圈节点,需要判断其后继是不是80,如果是才需要在该level链表中摘除,否则说明该level没有串联80节点。

跳表rank原理

之前说过,跳表计算rank实际是经历了一次对目标值的查找过程,并在这个过程中累加出来的。

在跳表中,会为每个节点在每一level维护下一跳的距离span值,比如level3中从header节点跳到20节点,实际跨越了5,11,所以header在level3的span=3。

随着对目标值68的查找,我们在不同level向右移动的过程中就只需要累加span,比如在level2中20跳30就只需要1步,所以span加1即可,最终我们可以得到68的rank其实就是3+1+1=5,即排名第5,其前方的数字是5,11,20,30,就是这样一个原理。

那么问题就是每个节点在不同level的span怎么维护比较高效?其实在插入/删除的过程中,我们可以顺便就把span更新了。

回到这张插入80的图片。

我们先圈出了在level1~3的3个前驱节点(20,30,68),它们在整个跳表中的rank我们都可以在推进过程中累加出来。

在level2,80链到30的后面,怎么算出30的span=2呢?首先我们知道68的rank,所以就知道80的rank=68的rank+1;我们也知道30的rank,所以用80的rank – 30的rank,就是30跳到80越过的节点个数,也就是30的span。

在level3,80链在68的后面,怎么算出68的span=1呢?一样的道理,我们知道68和80的rank,做减法就是68的span。

上述已经把受影响的前驱节点的span更新完成了,但是新插入节点80的span还没设置

其实我们在更新30和68的span之前,知道30和68的旧span值(30到99和68到99的跳数)。对于level2来说,只需要用30的旧span-30的新span就是80在level2的新span值。对于level3来说,只需要用68的旧span-68的新span就是80在level3的新span值。

这就可以了吗?

上面的有几张图片是错误的,它们在level3的20没有连到99上,在跳表中这是不可能存在的,一定会有索引链过去,这是网上的错误图片。

我们脑补一下最后一张图片中缺失的那根线,然后想一下level3的20的span的值需不需要更新?

答案当然是需要了,因为在20和99之间插入了一个80,这要是level3中20跳跃到99要经过的节点。

所以,对于高于插入节点的level,我们需要对圈红的节点的span+1处理。

最后

删除节点更新span比较简单,留给大家思考。

Coreseek 使用指南

Coreseek 使用指南

coreseek实际就是集成了中文分词组件mmseg的sphinx0.9/1.x的版本。

安装

1.mmseg

先安装mmseg

cd mmseg-3.2.14
./configure --prefix=/usr/local/mmseg

需要automake、libtool支持,如果报错

config.status: error: cannot find input file: src/Makefile.in

那么依次:

aclocal
libtoolize --force
automake --add-missing
autoconf
autoheader
make clean
./configure --prefix=/usr/local/mmseg
make && make install

这个prefix就是安装路径,后面安装csft要用。

gcc版本高,编译会报错,在2个cpp文件中有初始化字符数组越界。

应该是设置了-Werror选项,尝试编辑了一下configure文件,找到ac_cxx_werror_flag=yes这一行,改成no,结果还是不行。所以只好改下代码,找到这两个文件,src/mmseg_main.cpp和src/css/SynonymsDict.cpp,其中的char txtHead[3]这个列表初始化,改成txtHead[0]=239,txtHead[1]=187这样,至此可以编译通过。

2.csft-3.2.14

安装csft-3.2.14

./configure --prefix=/usr/local/coreseek --with-mysql --with-python --with-mmseg=/usr/local/mmseg --with-mmseg-includes=/usr/local/mmseg/include/mmseg/ --with-mmseg-libs=/usr/local/mmseg/lib/
make && make install

这个prefix是sphinx的路径,安装成功以后,二进制文件都在/usr/local/coreseek/bin下。with-mysql是提供mysql支持,如果你的mysql头文件不在系统路径里,你需要–with-mysql=…来选择mysql的路径。后面的就是mmseg文件的路径。

但编译一定会失败,需要修改代码:

  1. sphinxexpr.cpp这个文件,所有的
    T val = ExprEval ( this->m_pArg, tMatch );
    

    替换成

    T val = this->ExprEval ( this->m_pArg, tMatch );
    

    这个也是gcc版本的问题。

  2. indexer.cpp这个文件里,有一个返回值的错误,用false返回了空指针(C++03的语法),现在被视作不可用的类型转换,会报错。找到SpawnSourceXMLPipe这个函数,里面调用了LOC_CHECK这个宏定义,这个定义返回的是bool,和函数返回类型不一致。修改调用的地方,找到LOC_CHECK定义的地方,然后直接复制过来,把返回值false改成nullptr,不用这个宏定义了。接下来就能过编译了。

至此安装就成功了,不过这个版本是没有实时索引的。

3.csft 4.1

这个版本要通过脚本自动生成configure文件

首先修改configure.ac

找到

AM_INIT_AUTOMAKE([-Wall -Werror foreign]) 

去掉 -Werror,然后在文件末尾添加

AM_PROG_AR

其次修改Makefile.am,加上

AUTOMAKE_OPTIONS = subdir-objects

根目录和libstemmer_c目录下的Makefile.am都要修改。

然后./buildconf.sh,可能会报错,缺少某些组件,执行

automake --add-missing

然后应该就能生成configure了。

接下来编译,已知的错误如下:

  1. 和3.2.14一样,几个文件里的ExprEval报错,按照上面给出的方法修改。

  2. sphinx.cpp 3446行,修改返回值为nullptr。

  3. sphinxsort.cpp中,找到

    #define LOC_CHECK(cond,msg) if (!(_cond)) { sError = "invalid schema: " _msg; return false; }
    

    这个宏定义,返回值改成nullptr。

  4. py_layer.cpp,还是LOC_CHECK宏定义,返回值改成nullptr。发现在这个原文件里很多用bool的false作对象指针的返回值,这是c++03的语法,现在已经无法通过编译。所以多次编译,修改所有的这些语法错误,返回值都改成nullptr。

  5. 最后indexer.cpp的SpawnSourceXMLPipe方法,宏定义替换掉。方法上面介绍过。

现在能通过编译了。

./configure --prefix=/usr/local/coreseek --with-mysql --with-mmseg=/usr/local/mmseg --with-mmseg-includes=/usr/local/mmseg/include/mmseg/ --with-mmseg-libs=/usr/local/mmseg/lib/
make && make install

我提供了已经修改好的一份csft-4.1+csft-3.2.14+mmseg3代码,在gcc 7.3通过编译:coreseek-fixed.tar。这里面有已经编译好的二进制文件,不依赖环境,应该可以直接运行。可以直接根据上面给出的configure命令,把二进制文件复制到相应的位置使用。

使用

经过上面的步骤,coreseek+mmseg已经安装完成,接下来我将根据上面的安装选项介绍如何使用。

1.配置文件

配置文件的路径,默认为/usr/local/coreseek/etc/csft.conf。可以从testpack中给出的测试用例里面,复制一份然后改写。每项配置的具体说明已写在注释中供参考。

#MySQL数据源配置,详情请查看:http://www.coreseek.cn/products-install/mysql/
#请先将var/test/documents.sql导入数据库,并配置好以下的MySQL用户密码数据库

#源定义
source mysql
{
    type                    = mysql
    sql_host                = localhost
    sql_user                = root
    sql_pass                = sa
    sql_db                  = test
    sql_port                = 3306
    sql_query_pre           = SET NAMES utf8
    sql_query               = SELECT id, group_id, UNIX_TIMESTAMP(date_added) AS ate_added, title, content FROM documents
    #sql_query第一列id需为整数
    #title、content作为字符串/文本字段,被全文索引
    #接下来是属性的设置,属性是存在索引中的,它不进行全文索引,但是可以用于过滤和排序。
    sql_attr_uint            = group_id           #从SQL读取到的值必须为整数
    sql_attr_timestamp        = date_added #从SQL读取到的值必须为整数,作为时间属性,经常被用于做排序
    sql_attr_multi = uint tag from query;\
        SELECT id,tag FROM label
    #MVA属性
    sql_query_info_pre      = SET NAMES utf8                                        #命令行查询时,设置正确的字符集,如果不设置的话,无法通过test.py和search二进制程序进行中文的查询
    sql_query_info            = SELECT * FROM documents WHERE id=$id #命令行查询时,从数据库读取原始数据信息
    #数值类型uint,bool,timestamps,float,string,MVA(前面都是sql_attr_的前缀),被列出来的属性会作为查询的返回值。在这个例子中,查询会返回id,weight,group_id和date_added三个字段,id和weight是系统自动返回的,group_id和date_added则是我们设定的。只有字符串/文本字段会被索引。
    #设定了sql_attr_的字段,会直接被存储,不参与索引,并出现在sphinx提供的mysql接口中的表里,成为一个表项。通过python/php的api进行查询时,会返回被设定sql_attr_的表项。
    #MVA是多值属性用于将多个附加值赋给同一个属性。
}

#index定义
index mysql
{
    source            = mysql             #对应的source名称
    path            = /usr/local/coreseek/var/data/mysql #请修改为实际使用的绝对路径,例如:/usr/local/coreseek/var/...
    docinfo            = extern #这个类型下面会解释
    mlock            = 0
    morphology        = none
    min_word_len        = 1
    html_strip                = 0

    #中文分词配置,详情请查看:http://www.coreseek.cn/products-install/coreseek_mmseg/
    charset_dictpath = /usr/local/mmseg3/etc/ #BSD、Linux环境下设置,/符号结尾
    #charset_dictpath = etc/                             #Windows环境下设置,/符号结尾,最好给出绝对路径,例如:C:/usr/local/coreseek/etc/...
    charset_type        = zh_cn.utf-8
}

#实时索引
index rt
{
    type            = rt
    path            = /usr/local/coreseek/var/data/rtindex #请修改为实际使用的绝对路径,例如:/usr/local/coreseek/var/...
    docinfo            = extern
    mlock            = 0
    morphology        = none
    min_word_len        = 1
    html_strip                = 0

    #中文分词配置,详情请查看:http://www.coreseek.cn/products-install/coreseek_mmseg/
    charset_dictpath = /usr/local/mmseg/etc/ #BSD、Linux环境下设置,/符号结尾
    #charset_dictpath = etc/                             #Windows环境下设置,/符号结尾,最好给出绝对路径,例如:C:/usr/local/coreseek/etc/...
    charset_type        = zh_cn.utf-8

    #RT实时索引字段配置,详情请查看:http://www.coreseek.cn/products-install/rt-indexes/
    #字段设置顺序:field, uint, bigint, float, timestamp, string;顺序不可颠倒,否则产生混乱
    #使用后,不可更改字段设置,除非删除所有索引文件重新建立,否则产生混乱

    #文档编号字段
    #id                                               #系统自动处理

    #全文索引字段
    rt_field                  = title               #全文索引字段
    rt_field                  = content         #全文索引字段

    #属性字段
    rt_attr_uint            = groupid
    rt_attr_bigint         = biguid
    rt_attr_float           = score
    rt_attr_timestamp  = date_added

    #存储内容字段
    rt_attr_string          = author          #存储author的内容

    #已设置全文索引,并需要同时存储内容的字段
    rt_attr_string          = title              #同时存储title的内容
    rt_attr_string          = content        #同时存储content的内容

    #RT实时索引内存设置
    rt_mem_limit = 512M
}

}

#全局index定义
indexer
{
    mem_limit            = 128M
}

#searchd服务定义
searchd
{
    listen                  =   9312
    read_timeout        = 5
    max_children        = 30
    max_matches            = 1000
    seamless_rotate        = 0
    preopen_indexes        = 0
    unlink_old            = 1
    pid_file = /usr/local/coreseek/var/log/searchd_mysql.pid  #请修改为实际使用的绝对路径,例如:/usr/local/coreseek/var/...
    log = /usr/local/coreseek/var/log/searchd_mysql.log        #请修改为实际使用的绝对路径,例如:/usr/local/coreseek/var/...
    query_log = /usr/local/coreseek/var/log/query_mysql.log #请修改为实际使用的绝对路径,例如:/usr/local/coreseek/var/...
    binlog_path =  /usr/local/coreseek/var/log/rtindex                     #二进制日志
    binlog_flush = 2
    binlog_max_log_size = 16M
}

详细的设置比较复杂。我在网上找到了一篇比较详细的中文的sphinx3.x的配置文件:

## 数据源src1
source src1
{
    ## 说明数据源的类型。数据源的类型可以是:mysql,pgsql,mssql,xmlpipe,odbc,python
    ## 有人会奇怪,python是一种语言怎么可以成为数据源呢?
    ## python作为一种语言,可以操作任意其他的数据来源来获取数据,更多数据请看:(http://www.coreseek.cn/products-install/python/)
    type            = mysql

    ## 下面是sql数据库特有的端口,用户名,密码,数据库名等。
    sql_host        = localhost
    sql_user        = test
    sql_pass        =
    sql_db          = test
    sql_port        = 3306

    ## 如果是使用unix sock连接可以使用这个。
    # sql_sock      = /tmp/mysql.sock

    ## indexer和mysql之间的交互,需要考虑到效率和安全性。
    ## 比如考虑到效率,他们两者之间的交互需要使用压缩协议;考虑到安全,他们两者之间的传输需要使用ssl
    ## 那么这个参数就代表这个意思,0/32/2048/32768  无/使用压缩协议/握手后切换到ssl/Mysql 4.1版本身份认证。
    # mysql_connect_flags   = 32

    ## 当mysql_connect_flags设置为2048(ssl)的时候,下面几个就代表ssl连接所需要使用的几个参数。
    # mysql_ssl_cert        = /etc/ssl/client-cert.pem
    # mysql_ssl_key     = /etc/ssl/client-key.pem
    # mysql_ssl_ca      = /etc/ssl/cacert.pem

    ## mssql特有,是否使用windows登陆
    # mssql_winauth     = 1

    ## mssql特有,是使用unicode还是单字节数据。
    # mssql_unicode     = 1 # request Unicode data from server

    ## odbc的dsn串
    # odbc_dsn      = DBQ=C:\data;DefaultDir=C:\data;Driver={Microsoft Text Driver (*.txt; *.csv)};

    ## sql某一列的缓冲大小,一般是针对字符串来说的。
    ## 为什么要有这么一种缓冲呢?
    ## 有的字符串,虽然长度很长,但是实际上并没有使用那么长的字符,所以在Sphinx并不会收录所有的字符,而是给每个属性一个缓存作为长度限制。
    ## 默认情况下非字符类型的属性是1KB,字符类型的属性是1MB。
    ## 而如果想要配置这个buffer的话,就可以在这里进行配置了。
    # sql_column_buffers    = content=12M, comments=1M

    ## indexer的sql执行前需要执行的操作。
    # sql_query_pre     = SET NAMES utf8
    # sql_query_pre     = SET SESSION query_cache_type=OFF

    ## indexer的sql执行语句
    sql_query       = \
        SELECT id, group_id, UNIX_TIMESTAMP(date_added) AS date_added, title, content \
        FROM documents

    ## 有的时候有多个表,我们想要查询的字段在其他表中。这个时候就需要对sql_query进行join操作。
    ## 而这个join操作可能非常慢,导致建立索引的时候特别慢,那么这个时候,就可以考虑在sphinx端进行join操作了。
    ## sql_joined_field是增加一个字段,这个字段是从其他表查询中查询出来的。
    ## 这里封号后面的查询语句是有要求的,如果是query,则返回id和查询字段,如果是payload-query,则返回id,查询字段和权重。
    ## 并且这里的后一个查询需要按照id进行升序排列。
    # sql_joined_field  = tags from query; SELECT docid, CONCAT('tag',tagid) FROM tags ORDER BY docid ASC
    # sql_joined_field  = wtags from payload-query; SELECT docid, tag, tagweight FROM tags ORDER BY docid ASC

    ## 外部文件字段,意思就是一个表中,有一个字段存的是外部文件地址,但是实际的字段内容在文件中。比如这个字段叫做content_file_path。
    ## 当indexer建立索引的时候,查到这个字段,就读取这个文件地址,然后加载,并进行分词和索引建立等操作。
    # sql_file_field        = content_file_path

    ## 当数据源数据太大的时候,一个sql语句查询下来往往很有可能锁表等操作。
    ## 那么我么就可以使用多次查询,那么这个多次查询就需要有个范围和步长,sql_query_range和sql_range_step就是做这个使用的。
    ## 获取最大和最小的id,然后根据步长来获取数据。比如下面的例子,如果有4500条数据,这个表建立索引的时候就会进行5次sql查询。 
    ## 而5次sql查询每次的间隔时间是使用sql_ranged_rhrottle来进行设置的。单位是毫秒。
    # sql_query_range       = SELECT MIN(id),MAX(id) FROM documents
    # sql_range_step        = 1000
    # sql_ranged_throttle   = 0

    ## 下面都是些不同属性的数据了
    ## 先要了解属性的概念:属性是存在索引中的,它不进行全文索引,但是可以用于过滤和排序。

    ## uint无符号整型属性
    sql_attr_uint       = group_id

    ## bool属性
    # sql_attr_bool     = is_deleted

    ## 长整型属性
    # sql_attr_bigint       = my_bigint_id

    ## 时间戳属性,经常被用于做排序
    sql_attr_timestamp  = date_added

    ## 字符串排序属性。一般我们按照字符串排序的话,我们会将这个字符串存下来进入到索引中,然后在查询的时候比较索引中得字符大小进行排序。
    ## 但是这个时候索引就会很大,于是我们就想到了一个方法,我们在建立索引的时候,先将字符串值从数据库中取出,暂存,排序。
    ## 然后给排序后的数组分配一个序号,然后在建立索引的时候,就将这个序号存入到索引中去。这样在查询的时候也就能完成字符串排序的操作。
    ## 这,就是这个字段的意义。
    # sql_attr_str2ordinal  = author_name

    ## 浮点数属性,经常在查询地理经纬度的时候会用到。
    # sql_attr_float        = lat_radians
    # sql_attr_float        = long_radians

    ## 多值属性(MVA)
    ## 试想一下,有一个文章系统,每篇文章都有多个标签,这个文章就叫做多值属性。
    ## 我要对某个标签进行查询过滤,那么在建立查询的时候就应该把这个标签的值放入到索引中。
    ## 这个字段,sql_attr_multi就是用来做这个事情的。
    # sql_attr_multi        = uint tag from query; SELECT docid, tagid FROM tags
    # sql_attr_multi        = uint tag from ranged-query; \
    #   SELECT docid, tagid FROM tags WHERE id>=$start AND id<=$end; \
    #   SELECT MIN(docid), MAX(docid) FROM tags

    ## 字符串属性。
    # sql_attr_string       = stitle

    ## 文档词汇数记录属性。比如下面就是在索引建立的时候增加一个词汇数的字段
    # sql_attr_str2wordcount    = stitle

    ## 字符串字段,可全文搜索,可返回原始文本信息。
    # sql_field_string  = author

    ## 文档词汇数记录字段,可全文搜索,可返回原始信息
    # sql_field_str2wordcount   = title

    ## 取后查询,在sql_query执行后立即操作。
    ## 它和sql_query_post_index的区别就是执行时间不同
    ## sql_query_post是在sql_query执行后执行,而sql_query_post_index是在索引建立完成后才执行。
    ## 所以如果要记录最后索引执行时间,那么应该在sql_query_post_index中执行。
    # sql_query_post        =

    ## 参考sql_query_post的说明。
    # sql_query_post_index  = REPLACE INTO counters ( id, val ) \
    #   VALUES ( 'max_indexed_id', $maxid )

    ## 命令行获取信息查询。
    ## 什么意思呢?
    ## 我们进行索引一般只会返回主键id,而不会返回表中的所有字段。
    ## 但是在调试的时候,我们一般需要返回表中的字段,那这个时候,就需要使用sql_query_info。
    ## 同时这个字段只在控制台有效,在api中是无效的。
    sql_query_info      = SELECT * FROM documents WHERE id=$id

    ## 比如有两个索引,一个索引比较旧,一个索引比较新,那么旧索引中就会有数据是旧的。
    ## 当我要对两个索引进行搜索的时候,哪些数据要按照新的索引来进行查询呢。
    ## 这个时候就使用到了这个字段了。
    ## 这里的例子(http://www.coreseek.cn/docs/coreseek_4.1-sphinx_2.0.1-beta.html#conf-sql-query-killlist)给的非常清晰了。
    # sql_query_killlist    = SELECT id FROM documents WHERE edited>=@last_reindex

    ## 下面几个压缩解压的配置都是为了一个目的:让索引重建的时候不要影响数据库的性能表现。
    ## SQL数据源解压字段设置
    # unpack_zlib       = zlib_column
    ## MySQL数据源解压字段设置
    # unpack_mysqlcompress  = compressed_column
    # unpack_mysqlcompress  = compressed_column_2
    ## MySQL数据源解压缓冲区设置
    # unpack_mysqlcompress_maxsize  = 16M


    ## xmlpipe的数据源就是一个xml文档
    # type          = xmlpipe

    ## 读取数据源的命令
    # xmlpipe_command       = cat /home/yejianfeng/instance/coreseek/var/test.xml

    ## 字段
    # xmlpipe_field     = subject
    # xmlpipe_field     = content

    ## 属性
    # xmlpipe_attr_timestamp    = published
    # xmlpipe_attr_uint = author_id

    ## UTF-8修复设置
    ## 只适用xmlpipe2数据源,数据源中有可能有非utf-8的字符,这个时候解析就有可能出现问题
    ## 如果设置了这个字段,非utf-8序列就会全部被替换为空格。
    # xmlpipe_fixup_utf8    = 1
}

## sphinx的source是有继承这么一种属性的,意思就是除了父source之外,这个source还有这个特性
source src1throttled : src1
{
    sql_ranged_throttle = 100
}

## 索引test1
index test1
{
    ## 索引类型,包括有plain,distributed和rt。分别是普通索引/分布式索引/增量索引。默认是plain。
    # type          = plain

    ## 索引数据源
    source          = src1
    ## 索引文件存放路径
    path            = /home/yejianfeng/instance/coreseek/var/data/test1

    ## 文档信息的存储模式,包括有none,extern,inline。默认是extern。
    ## docinfo指的就是数据的所有属性(field)构成的一个集合。
    ## 首先文档id是存储在一个文件中的(spa)
    ## 当使用inline的时候,文档的属性和文件的id都是存放在spa中的,所以进行查询过滤的时候,不需要进行额外操作。
    ## 当使用extern的时候,文档的属性是存放在另外一个文件(spd)中的,但是当启动searchd的时候,会把这个文件加载到内存中。
    ## extern就意味着每次做查询过滤的时候,除了查找文档id之外,还需要去内存中根据属性进行过滤。
    ## 但是即使这样,extern由于文件大小小,效率也不低。所以不是有特殊要求,一般都是使用extern
    docinfo         = extern

    ## 缓冲内存锁定。
    ## searchd会讲spa和spi预读取到内存中。但是如果这部分内存数据长时间没有访问,则它会被交换到磁盘上。
    ## 设置了mlock就不会出现这个问题,这部分数据会一直存放在内存中的。
    mlock           = 0

    ## 词形处理器
    ## 词形处理是什么意思呢?比如在英语中,dogs是dog的复数,所以dog是dogs的词干,这两个实际上是同一个词。
    ## 所以英语的词形处理器会讲dogs当做dog来进行处理。
    morphology      = none

    ## 词形处理有的时候会有问题,比如将gps处理成gp,这个设置可以允许根据词的长度来决定是否要使用词形处理器。
    # min_stemming_len  = 1

    ## 词形处理后是否还要检索原词?
    # index_exact_words = 1

    ## 停止词,停止词是不被索引的词。
    # stopwords     = /home/yejianfeng/instance/coreseek/var/data/stopwords.txt

    ## 自定义词形字典
    # wordforms     = /home/yejianfeng/instance/coreseek/var/data/wordforms.txt

    ## 词汇特殊处理。
    ## 有的一些特殊词我们希望把它当成另外一个词来处理。比如,c++ => cplusplus来处理。
    # exceptions        = /home/yejianfeng/instance/coreseek/var/data/exceptions.txt

    ## 最小索引词长度,小于这个长度的词不会被索引。
    min_word_len        = 1

    ## 字符集编码类型,可以为sbcs,utf-8。对于Coreseek,还可以有zh_cn.utf-8,zh_ch.gbk,zh_ch.big5
    charset_type        = sbcs

    ## 字符表和大小写转换规则。对于Coreseek,这个字段无效。
    # 'sbcs' default value is
    # charset_table     = 0..9, A..Z->a..z, _, a..z, U+A8->U+B8, U+B8, U+C0..U+DF->U+E0..U+FF, U+E0..U+FF
    #
    # 'utf-8' default value is
    # charset_table     = 0..9, A..Z->a..z, _, a..z, U+410..U+42F->U+430..U+44F, U+430..U+44F

    ## 忽略字符表。在忽略字符表中的前后词会被连起来当做一个单独关键词处理。
    # ignore_chars      = U+00AD

    ## 是否启用通配符,默认为0,不启用
    # enable_star       = 1

    ## min_prefix_len,min_infix_len,prefix_fields,infix_fields都是在enable_star开启的时候才有效果。
    ## 最小前缀索引长度
    ## 为什么要有这个配置项呢?
    ## 首先这个是当启用通配符配置启用的前提下说的,前缀索引使得一个关键词产生了多个索引项,导致索引文件体积和搜索时间增加巨大。
    ## 那么我们就有必要限制下前缀索引的前缀长度,比如example,当前缀索引长度设置为5的时候,它只会分解为exampl,example了。
    # min_prefix_len        = 0
    ## 最小索引中缀长度。理解同上。
    # min_infix_len     = 0

    ## 前缀索引和中缀索引字段列表。并不是所有的字段都需要进行前缀和中缀索引。
    # prefix_fields     = filename
    # infix_fields      = url, domain

    ## 词汇展开
    ## 是否尽可能展开关键字的精确格式或者型号形式
    # expand_keywords       = 1

    ## N-Gram索引的分词技术
    ## N-Gram是指不按照词典,而是按照字长来分词,这个主要是针对非英文体系的一些语言来做的(中文、韩文、日文)
    ## 对coreseek来说,这两个配置项可以忽略。
    # ngram_len     = 1
    # ngram_chars       = U+3000..U+2FA1F

    ## 词组边界符列表和步长
    ## 哪些字符被看做分隔不同词组的边界。
    # phrase_boundary       = ., ?, !, U+2026 # horizontal ellipsis
    # phrase_boundary_step  = 100

    ## 混合字符列表
    # blend_chars       = +, &, U+23
    # blend_mode        = trim_tail, skip_pure

    ## html标记清理,是否从输出全文数据中去除HTML标记。
    html_strip      = 0

    ## HTML标记属性索引设置。
    # html_index_attrs  = img=alt,title; a=title;

    ## 需要清理的html元素
    # html_remove_elements  = style, script

    ## searchd是预先打开全部索引还是每次查询再打开索引。
    # preopen           = 1

    ## 字典文件是保持在磁盘上还是将他预先缓冲在内存中。
    # ondisk_dict       = 1

    ## 由于在索引建立的时候,需要建立临时文件和和副本,还有旧的索引
    ## 这个时候磁盘使用量会暴增,于是有个方法是临时文件重复利用
    ## 这个配置会极大减少建立索引时候的磁盘压力,代价是索引建立速度变慢。
    # inplace_enable        = 1
    # inplace_hit_gap       = 0 # preallocated hitlist gap size
    # inplace_docinfo_gap   = 0 # preallocated docinfo gap size
    # inplace_reloc_factor  = 0.1 # relocation buffer size within arena
    # inplace_write_factor  = 0.1 # write buffer size within arena

    ## 在经过过短的位置后增加位置值
    # overshort_step        = 1

    ## 在经过 停用词 处后增加位置值
    # stopword_step     = 1

    ## 位置忽略词汇列表
    # hitless_words     = all
    # hitless_words     = hitless.txt

    ## 是否检测并索引句子和段落边界
    # index_sp          = 1

    ## 字段内需要索引的HTML/XML区域的标签列表
    # index_zones       = title, h*, th
}

index test1stemmed : test1
{
    path            = /home/yejianfeng/instance/coreseek/var/data/test1stemmed
    morphology      = stem_en
}

index dist1
{
    type            = distributed

    local           = test1
    local           = test1stemmed

    ## 分布式索引(distributed index)中的远程代理和索引声明
    agent           = localhost:9313:remote1
    agent           = localhost:9314:remote2,remote3
    # agent         = /var/run/searchd.sock:remote4

    ## 分布式索引( distributed index)中声明远程黑洞代理
    # agent_blackhole       = testbox:9312:testindex1,testindex2

    ## 远程代理的连接超时时间
    agent_connect_timeout   = 1000

    ## 远程查询超时时间
    agent_query_timeout = 3000
}

index rt
{
    type            = rt

    path            = /home/yejianfeng/instance/coreseek/var/data/rt

    ## RT索引内存限制
    # rt_mem_limit      = 512M

    ## 全文字段定义
    rt_field        = title
    rt_field        = content

    ## 无符号整数属性定义
    rt_attr_uint        = gid

    ## 各种属性定义
    # rt_attr_bigint        = guid
    # rt_attr_float     = gpa
    # rt_attr_timestamp = ts_added
    # rt_attr_string        = author
}

indexer
{
    ## 建立索引的时候,索引内存限制
    mem_limit       = 32M

    ## 每秒最大I/O操作次数,用于限制I/O操作
    # max_iops      = 40

    ## 最大允许的I/O操作大小,以字节为单位,用于I/O节流
    # max_iosize        = 1048576

    ## 对于XMLLpipe2数据源允许的最大的字段大小,以字节为单位
    # max_xmlpipe2_field    = 4M

    ## 写缓冲区的大小,单位是字节
    # write_buffer      = 1M

    ## 文件字段可用的最大缓冲区大小,字节为单位
    # max_file_field_buffer = 32M
}

## 搜索服务配置
searchd
{
    # listen            = 127.0.0.1
    # listen            = 192.168.0.1:9312
    # listen            = 9312
    # listen            = /var/run/searchd.sock

    ## 监听端口
    listen          = 9312
    listen          = 9306:mysql41

    ## 监听日志
    log         = /home/yejianfeng/instance/coreseek/var/log/searchd.log

    ## 查询日志
    query_log       = /home/yejianfeng/instance/coreseek/var/log/query.log

    ## 客户端读超时时间 
    read_timeout        = 5

    ## 客户端持久连接超时时间,即客户端读一次以后,持久连接,然后再读一次。中间这个持久连接的时间。
    client_timeout      = 300

    ## 并行执行搜索的数目
    max_children        = 30

    ## 进程id文件
    pid_file        = /home/yejianfeng/instance/coreseek/var/log/searchd.pid

    ## 守护进程在内存中为每个索引所保持并返回给客户端的匹配数目的最大值
    max_matches     = 1000

    ## 无缝轮转。防止 searchd 轮换在需要预取大量数据的索引时停止响应
    ## 当进行索引轮换的时候,可能需要消耗大量的时间在轮换索引上。
    ## 但是启动了无缝轮转,就以消耗内存为代价减少轮转的时间
    seamless_rotate     = 1

    ## 索引预开启,是否强制重新打开所有索引文件
    preopen_indexes     = 1

    ## 索引轮换成功之后,是否删除以.old为扩展名的索引拷贝
    unlink_old      = 1

    ## 属性刷新周期
    ## 就是使用UpdateAttributes()更新的文档属性每隔多少时间写回到磁盘中。
    # attr_flush_period = 900

    ## 索引字典存储方式
    # ondisk_dict_default   = 1

    ## 用于多值属性MVA更新的存储空间的内存共享池大小
    mva_updates_pool    = 1M

    ## 网络通讯时允许的最大的包的大小
    max_packet_size     = 8M

    ## 崩溃日志文件
    # crash_log_path        = /home/yejianfeng/instance/coreseek/var/log/crash

    ## 每次查询允许设置的过滤器的最大个数
    max_filters     = 256

    ## 单个过滤器允许的值的最大个数
    max_filter_values   = 4096

    ## TCP监听待处理队列长度
    # listen_backlog        = 5

    ## 每个关键字的读缓冲区的大小
    # read_buffer       = 256K

    ## 无匹配时读操作的大小
    # read_unhinted     = 32K

    ## 每次批量查询的查询数限制
    max_batch_queries   = 32

    ## 每个查询的公共子树文档缓存大小
    # subtree_docs_cache    = 4M

    ## 每个查询的公共子树命中缓存大小
    # subtree_hits_cache    = 8M

    ## 多处理模式(MPM)。 可选项;可用值为none、fork、prefork,以及threads。 默认在Unix类系统为form,Windows系统为threads。
    workers         = threads # for RT to work

    ## 并发查询线程数
    # dist_threads      = 4

    ## 二进制日志路径
    # binlog_path       = # disable logging
    # binlog_path       = /home/yejianfeng/instance/coreseek/var/data # binlog.001 etc will be created there

    ## 二进制日志刷新
    # binlog_flush      = 2

    ## 二进制日志大小限制
    # binlog_max_log_size   = 256M

    ## 线程堆栈
    # thread_stack          = 128K

    ## 关键字展开限制
    # expansion_limit       = 1000

    ## RT索引刷新周期 
    # rt_flush_period       = 900

    ## 查询日志格式
    ## 可选项,可用值为plain、sphinxql,默认为plain。 
    # query_log_format      = sphinxql

    ## MySQL版本设置
    # mysql_version_string  = 5.0.37

    ## 插件目录
    # plugin_dir            = /usr/local/sphinx/lib

    ## 服务端默认字符集
    # collation_server      = utf8_general_ci
    ## 服务端libc字符集
    # collation_libc_locale = ru_RU.UTF-8

    ## 线程服务看守
    # watchdog              = 1
    ## 兼容模式
    # compat_sphinxql_magics    = 1
}

这份配置文件属于sphinx3.x,coreseek4.1的sphinx版本是2.x。有一些选项不兼容,但是coreseek4.1也是支持索引和数据源的继承的,分布式索引也是支持的。这是一份中文的coreseek手册:CoreSeekSphinx手册,这份手册是基于coreseek4.1的。可以两份配置文件参考着看。

2.api

coreseek提供了Java、Php、Ruby和Python的API,API的命名基本是一致的。注意coreseek提供的python接口还是python2的,而最新版的sphinx3.x已经更新为python3。

这里以php的接口为例:

$cl = new SphinxClient ();
$q = "";
$sql = "";
$mode = SPH_MATCH_ALL; #匹配模式,不同的类型pdf手册里有介绍
$host = "localhost";
$port = 9312;
$index = "*";
$groupby = "";
$groupsort = "@group desc";
$filter = "group_id";
$filtervals = array();
$distinct = "";
$sortby = "";
$sortexpr = "";
$limit = 20;
$ranker = SPH_RANK_PROXIMITY_BM25; #评分模式,见pdf手册
$select = "";

$cl->SetServer ( $host, $port );
$cl->SetConnectTimeout ( 1 );
$cl->SetArrayResult ( true );
$cl->SetRankingMode ( $ranker );
$res = $cl->Query ( $q, $index );

if ( $res===false )
{
    print "Query failed: " . $cl->GetLastError() . ".\n";

} else
{
    if ( $cl->GetLastWarning() )
        print "WARNING: " . $cl->GetLastWarning() . "\n\n";

    print "Query '$q' retrieved $res[total] of $res[total_found] matches in $res[time] sec.\n";
    print "Query stats:\n";
    if ( is_array($res["words"]) )
        foreach ( $res["words"] as $word => $info )
            print "    '$word' found $info[hits] times in $info[docs] documents\n";
    print "\n";

    if ( is_array($res["matches"]) )
    {
        $n = 1;
        print "Matches:\n";
        foreach ( $res["matches"] as $docinfo )
        {
            print "$n. doc_id=$docinfo[id], weight=$docinfo[weight]";
            foreach ( $res["attrs"] as $attrname => $attrtype )
            {
                $value = $docinfo["attrs"][$attrname];
                if ( $attrtype==SPH_ATTR_MULTI || $attrtype==SPH_ATTR_MULTI64 )
                {
                    $value = "(" . join ( ",", $value ) .")";
                } else
                {
                    if ( $attrtype==SPH_ATTR_TIMESTAMP )
                        $value = date ( "Y-m-d H:i:s", $value );
                }
                print ", $attrname=$value";
            }
            print "\n";
            $n++;
        }
    }
}

//¥res变量中存储了检索的结果:总共检索$res[total]个条目,匹配$res[total_found]个,耗时$res[time]秒;分组检索的结果在array $res["words"]中。

具体的用法要查看test.php和sphinxapi.php,里面的英文注释很详尽。

3.实例

1.普通索引

这是我写的一个普通索引的实例,应该算是涵盖所有基本功能了:

首先配置数据库:

表documents:documents

主键是id,注意字符集,其中title和content是全文索引的字段,这些在配置文件中设置。

表label:lable

这个表用来进行MVA的索引,一个id对应多个tag,所以主键是(id,tag)。我插入的值是这样的:

id tag
1 1
1 2
1 3

意思就是说id=1的文档有1,2,3,3个tag。这个tag就是用来筛选用的,在索引中实现类似于MySQL的where in这样的功能。原始的索引是不支持的。我们可以通过MVA,来指定只从拥有某些tag的文档中进行匹配。

documents表中填充要索引的条目就好。注意,id必须是从1开始自增的。

以下是配置文件:

数据源:

source mysql
{
    type                    = mysql

    sql_host                = 192.168.1.100
    sql_user                = root
    sql_pass                = sa
    sql_db                    = test
    sql_port                = 3306
    sql_query_pre            = SET NAMES utf8

    sql_query                = SELECT id, group_id,group_id2, UNIX_TIMESTAMP(date_added) AS date_added, title, content FROM documents

    sql_attr_uint            = group_id 
    sql_attr_timestamp        = date_added 
    sql_attr_multi = uint tag from query;\
        SELECT id,tag FROM label

    sql_query_info_pre      = SET NAMES utf8                        
    sql_query_info            = SELECT * FROM documents WHERE id=$id 
}

要注意,并不是只有文本类型能索引。在这里我从数据库里取出了group_id2这个字段,这个字段是int类型的,也参加索引。之前在介绍配置文件的时候说过,没有被列出来(sql_attr_xxx)的属性,都会参加索引。被列出来的属性,会被存储在sphinx的索引表中,可以通过查询的返回值输出。

索引及守护进程:

index mysql
{
    source            = mysql            
    path            = /usr/local/coreseek/var/data/mysql
    docinfo            = extern
    mlock            = 0
    morphology        = none
    min_word_len        = 1
    html_strip                = 0
    charset_dictpath = /usr/local/mmseg/etc/ 
    charset_type        = zh_cn.utf-8
}

indexer
{
    mem_limit            = 128M
}

searchd
{
    workers = threads
    listen                  =   9312
    listen = 0.0.0.0:9011:mysql41
    read_timeout        = 5
    max_children        = 30
    max_matches            = 1000
    seamless_rotate        = 0
    preopen_indexes        = 0
    unlink_old            = 1
    pid_file = /usr/local/coreseek/var/log/searchd_mysql.pid 
    log = /usr/local/coreseek/var/log/searchd_mysql.log       
    query_log = /usr/local/coreseek/var/log/query_mysql.log
    binlog_path =  /usr/local/coreseek/var/log/rtindex              
    binlog_flush = 2
    binlog_max_log_size = 16M
}

配置完毕后启动守护进程searchd。

这里我用php的接口进行查询,这是我的php程序:

<?php
require("sphinxapi.php");
$cl = new SphinxClient();
$q = "父亲的背影";
$index = "mysql";
$mode = SPH_MATCH_ALL;
$cl->SetServer("localhost",9312);
$cl->SetArrayResult(true);
$cl->SetWeights(array(100,1));
$cl->SetMatchMode($mode);
$cl->SetFilter('tag',array(1));
$cl->SetFilter('tag',array(2));
$cl->SetFilter('group_id',array(2));
//SetFilter字段是用来设置过滤的,这里我选择了,从tag有1,2两项,以及group_id=2的条目中匹配。这个字段必须是之前设置过sql_attr_xxx的字段,比如如果我在这里去筛选之前没有设置过的group_id2字段,查询会报错。
$res = $cl->Query($q,$index);
if($res)
{
        print "Query Success\n";
        print "$res[total_found]\n";
        if(is_array($res["matches"]))
        {
                foreach($res["words"] as $word  => $info)
                        print "$word,$info[hits]\n";
                foreach($res["matches"] as $docinfo)
                {
                        print "doc_id=$docinfo[id],weight=$docinfo[weight],";
                        foreach($res["attrs"] as $attrname => $attrtype)
                        {
                                $value = $docinfo["attrs"][$attrname];
                                if($attrtype==SPH_ATTR_TIMESTAMP)
                                        $value = date("Y-m-d H:i:s",$value);
                                if ( $attrtype==SPH_ATTR_MULTI || $attrtype==SPH_ATTR_MULTI64 )
                                {
                                        $value = "(" . join ( ",", $value ) .")";
                                }
                                print "$attrname=$value\n";
                        }
                }
        }
}
?>

输出结果:

Query Success
1
父亲,11
的,29
背影,5
doc_id=1,weight=3,group_id=2
date_added=2018-11-27 19:55:57
tag=(1,2,3)

我们之前设定的字段都返回了。

现在修改关键字为3试试,即在我的表中,3是group_id2的值。输出:

Query Success
1
3,1
doc_id=1,weight=100,group_id=2
date_added=2018-11-27 19:55:57
tag=(1,2,3)

3只在group_id2中出现了一次,符合预期。

特别地,如果需要批量查询,可以使用AddQuery()接口,它的参数和Query()接口一样,然后调用RunQueries()接口执行。返回的res也是array。如果你的查询有关联,批量查询的性能会更好,因为其内部有优化措施。所以在所有查询都无关的情况下,应该采取单次查询。具体用法见中文手册。

2.MySQL接口

在介绍实时索引之前,必须先介绍MySQL的接口。

sphinx提供了一个MySQL接口,可以用标准的mysqlclient登陆(mariadb客户端不行)。我测试过,也支持MySQL的C API去连接、查询和获取返回。但是它的语法和MySQL不完全一样,官网把它的语言叫SphinxQL。

coreseek4.1的内核是sphinx2.0.2,这是sphinx官方文档的地址:

Sphinx 2.0.2-beta reference manual

通过标准MysqlClient连接,成功后提示如下:

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 1
Server version: 2.0.2-dev (r2922)

这个2.0.2就是sphinx的版本信息。

可以通过SQL输出索引mysql的信息:

mysql> select * from mysql;
+------+--------+----------+------------+
| id   | weight | group_id | date_added |
+------+--------+----------+------------+
|    1 |      1 |        2 | 1270131607 |
|    2 |      1 |        3 | 1270135548 |
|    3 |      1 |        3 | 1270094460 |
+------+--------+----------+------------+
3 rows in set (0.36 sec)

这个表和例1用的数据不同,但是结构相同。这里我插入了3条中文数据。

可以通过match来简单匹配条目:

mysql> select * from mysql where match('抢先版');
+------+--------+----------+------------+
| id   | weight | group_id | date_added |
+------+--------+----------+------------+
|    3 |   1680 |        3 | 1270094460 |
+------+--------+----------+------------+
1 row in set (0.06 sec)

目前已知一个bug,通过match进行匹配查询,大概率会导致searchd进程崩溃,原因未查明。但你仍然可以通过php等API实现查询,这些我测过没有bug。所以我建议不要用它提供的where match这个功能,实在太危险。

如果设置了MVA,还可以这样查询:

mysql> select * from mysql where tag=1;
+------+--------+----------+------------+-------+
| id   | weight | group_id | date_added | tag   |
+------+--------+----------+------------+-------+
|    1 |      1 |        2 | 1543319757 | 1,2,3 |
+------+--------+----------+------------+-------+
1 row in set (0.00 sec)

mysql> select * from mysql where tag=2 and group_id=2;
+------+--------+----------+------------+-------+
| id   | weight | group_id | date_added | tag   |
+------+--------+----------+------------+-------+
|    1 |      1 |        2 | 1543319757 | 1,2,3 |
+------+--------+----------+------------+-------+
1 row in set (0.00 sec)

mysql> select * from mysql where tag=4;
Empty set (0.00 sec)

可以通过tag筛选条目。不过这个等号的匹配只支持数值类型,字符串是没办法的。而字符串匹配的match现在又bug,不建议使用。

普通索引是不支持修改的,但是实时索引支持。如果尝试用sphinx提供的insert、replace、delete修改普通索引,会报错,提示not support。具体的语法查阅中文手册。

3.实时索引

实时索引和普通索引的区别还是很大的。

实时索引不需要配置数据源,只需要把类型设置为rt。以下为配置:

index rt
{
    type                    = rt
    path            = /usr/local/coreseek/var/data/rtindex
    docinfo            = extern
    mlock            = 0
    morphology        = none
    min_word_len        = 1
    html_strip                = 0

    charset_dictpath = /usr/local/mmseg/etc/ 
    charset_type        = zh_cn.utf-8

    rt_field                  = title      
    rt_field                  = content        

    rt_attr_uint            = groupid
    rt_attr_bigint         = biguid
    rt_attr_float           = score
    rt_attr_timestamp  = date_added
    rt_attr_string          = author 
    rt_attr_string          = title
    rt_attr_string          = content  

    rt_mem_limit = 512M
}

其余的配置和普通索引相同。

索引的字段要被设为rt_field,这里是title和content,其余attr字段都存储在表中。和普通索引不同的是,在实时索引中,设定了全文索引的字段,也可以存储在表中。但是在普通索引中,一个字段被存储,就不会参与索引(因为没有这个需求,可以通过id查MySQL获取条目信息,而且开销过大)。

对实时索引的操作,需要用到sphinx提供的MySQL接口。我们需要通过MySQL客户端连接searchd提供的端口:listen = 0.0.0.0:9011:mysql41,我这里设置的是9011。

现在通过mysql访问这个索引:

mysql> select * from rt;
Empty set (0.00 sec)

是空的。再通过python api查询下:

navazil@root:~/api$ python2 test.py -i rt
Query '' retrieved 0 of 0 matches in 0.000 sec
Query stats:

Matches:

结果是正确的。现在尝试往表里添加元素,注意语法:

mysql> insert into rt(id,groupid,biguid,score,author,title,content) values(1,1,9999,1.23456,'zkz','这是一次中文实时索引设置','你也在网上冲浪啊,你是GG还是MM?');
Query OK, 1 row affected (0.00 sec)
mysql> select * from rt;
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
| id   | weight | groupid | biguid | score    | date_added | author | title                                | content                                        |
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
|    1 |      1 |       1 |   9999 | 1.234560 |          0 | zkz    | 这是一次中文实时索引设置             | 你也在网上冲浪啊,你是GG还是MM?               |
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
1 row in set (0.00 sec)

现在有结果了。用python API查询下:

navazil@root:~/api$ python2 test.py -i rt 上网冲浪
Query '上网冲浪 ' retrieved 0 of 0 matches in 0.000 sec
Query stats:
        '上网' found 0 times in 0 documents
        '冲浪' found 1 times in 1 documents

Matches:
navazil@root:~/api$ python2 test.py -i rt 网上冲浪
Query '网上冲浪 ' retrieved 1 of 1 matches in 0.000 sec
Query stats:
        '网上' found 1 times in 1 documents
        '冲浪' found 1 times in 1 documents

Matches:
1. doc_id=1, weight=2, groupid=1, biguid=9999, score=1.23456001282, date_added=1970-01-01 08:00:00, author=zkz, title=这是一次中文实时索引设置, content=你也在网上冲浪啊,你是GG还是MM?
navazil@root:~/api$ python2 test.py -i rt 实时索引
Query '实时索引 ' retrieved 1 of 1 matches in 0.000 sec
Query stats:
        '实时' found 1 times in 1 documents
        '索引' found 1 times in 1 documents

Matches:
1. doc_id=1, weight=200, groupid=1, biguid=9999, score=1.23456001282, date_added=1970-01-01 08:00:00, author=zkz, title=这是一次中文实时索引设置, content=你也在网上冲浪啊,你是GG还是MM?

现在修改一下我们刚才写的php程序,检测一下我们设置的过滤器是否正常工作。

$q = "网上冲浪";
#$cl->SetFilter('tag',array(1));
#$cl->SetFilter('tag',array(2));
$cl->SetFilter('groupid',array(1));
//tag字段没有了,这里指设置groupid等于我们刚才插入的1

输出:

Query Success
1
网上,1
冲浪,1
doc_id=1,weight=2,groupid=1
biguid=9999
score=1.2345600128174
date_added=1970-01-01 08:00:00
author=zkz
title=这是一次中文实时索引设置
content=你也在网上冲浪啊,你是GG还是MM?

现在把上面设置的过滤id改为2:

Query Success
0

结果正确。实时索引在内存中做修改,然后定期刷写文件。刷写的选项,配置文件里都可选,具体要参考手册。

如果我们需要修改一项内容,sphinxql提供了replace into的语法。现在我们尝试为之前的条目加一条时间戳:

mysql> replace into rt(id,groupid,biguid,score,date_added,author,title,content) values(1,1,9999,1.23456,153456,'zkz','这是一次中文实时索引设置','你也在网上冲浪啊,你是GG还是MM?');
Query OK, 1 row affected (0.00 sec)
mysql> select * from rt;
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
| id   | weight | groupid | biguid | score    | date_added | author | title                                | content                                        |
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
|    1 |      1 |       1 |   9999 | 1.234560 |     153456 | zkz    | 这是一次中文实时索引设置             | 你也在网上冲浪啊,你是GG还是MM?               |
+------+--------+---------+--------+----------+------------+--------+--------------------------------------+------------------------------------------------+
1 row in set (0.00 sec)

这样时间项就有了。这里要特别注意,replace into和mysql的功能是一样的,它会替代已有的条目,也就是说,你没有在values中设置的值,会被重置为空(default)!所以假如你只需要更新某一列,你需要填充所有的其他列和原来一样,否则你就无法正确更新条目。用php查询下试试:

Query Success
1
网上,1
冲浪,1
doc_id=1,weight=2,groupid=1
biguid=9999
score=1.2345600128174
date_added=1970-01-03 02:37:36
author=zkz
title=这是一次中文实时索引设置
content=你也在网上冲浪啊,你是GG还是MM?

可见时间戳变了。

假如你对已有的id进行insert into,那么也会报错:

mysql> insert into rt(id,groupid,biguid,score,date_added,author,title,content) values(1,1,9999,1.23456,153456,'zkz','这是一次中文实时索引设置','你也在网上冲浪啊,你是GG还是MM?');
ERROR 1064 (42000): duplicate id '1'

如果需要删除一个条目,执行delete即可:

mysql> delete from rt where id=1;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from rt;
Empty set (0.00 sec)

delete的where只能用id去匹配,不支持其他字段。

传参规范

正确选择参数:分清输入参数、输出参数,分清值参数和引用参数。正确地传递参数。
正确选择参数是通过值、通过引用还是通过指针传递,是一种能够最大程度提高安全性和效率的好习惯。
选择如何传递参数时,应该遵循一下准则。对于只输入参数:
1.始终用const限制所有指向只输入参数的指针和应用。
2.优先通过值来取得原始类型(如char、float)和复制开销比较低的值对象(如point、complex<float>)的输入。
3.优先按const的引用取得其他用户定义类型的输入;
4.如果函数需要其参数的副本,则可以通过值传递代替
对于输出参数或者输入/输出参数:
1.如果参数是可选的(这样调用者可以传递null表示“不适用的”或“无需关心的”值),或者函数需要保存这个指针的副本或者操控参数的所有权,那么应该优先通过(智能)指针传递;
2.如果参数是必须的,而且函数无需保存指向参数的指针,或者无需操控其所有权,那么应该优先通过引用传递。这表明参数是必须的,而且调用者必须提供有效对象。

C++高级特性

C++高级特性

主要以C++11/C++14为主,整理了一下我在日常工作中经常用到的新特性。

1. 变量和基本类型

1.1 long long 类型

扩展精度浮点数,10位有效数字。

1.2 容器列表初始化

在我们实际编程中,我们经常会碰到变量初始化的问题,对于不同的变量初始化的手段多种多样,比如说对于一个数组我们可以使用 int arr[] = {1,2,3}的方式初始化,又比如对于一个简单的结构体:

struct A
{
	int x;
	int y;
}a={1,2};

这些不同的初始化方法都有各自的适用范围和作用,且对于类来说不能用这种初始化的方法,最主要的是没有一种可以通用的初始化方法适用所有的场景,因此C++11中为了统一初始化方式,提出了列表初始化(list-initialization)的概念。

在C++98/03中我们只能对普通数组和POD(plain old data,简单来说就是可以用memcpy复制的对象)类型可以使用列表初始化,在C++11中初始化列表被适用性被放大,可以作用于任何类型对象的初始化。如下:

class Foo
{
public:
	Foo(int) {}
private:
	Foo(const Foo &);
};
 
int _tmain(int argc, _TCHAR* argv[])
{
	Foo a1(123); //调用Foo(int)构造函数初始化
	Foo a2 = 123; //error Foo的拷贝构造函数声明为私有的,该处的初始化方式是隐式调用Foo(int)构造函数生成一个临时的匿名对象,再调用拷贝构造函数完成初始化
 
	Foo a3 = { 123 }; //列表初始化
	Foo a4 { 123 }; //列表初始化
 
	int a5 = { 3 };
	int a6 { 3 };
	return 0;
}

由上面的示例代码可以看出,在C++11中,列表初始化不仅能完成对普通类型的初始化,还能完成对类的列表初始化,需要注意的是a3、a4都是列表初始化,私有的拷贝并不影响它,仅调用类的构造函数而不需要拷贝构造函数,a4、a6的写法是C++98/03所不具备的,是C++11新增的写法。

同时列表初始化方法也适用于用new操作等圆括号进行初始化的地方,如下:

int* a = new int { 3 };
double b = double{ 12.12 };
int * arr = new int[] {1, 2, 3};

在C++11中可以使用列表初始化方法对堆中分配的内存的数组进行初始化,而在C++98/03中是不能这样做的。此外,还有一些细节需要注意:

struct A
{
	int x;
	int y;
}a = {123, 321};
 //a.x = 123 a.y = 321
 
struct B
{
	int x;
	int y;
	B(int, int) :x(0), y(0){}
}b = {123,321};
//b.x = 0  b.y = 0

对于自定义的结构体A来说模式普通的POD类型,使用列表初始化并不会引起问题,x,y都被正确的初始化了,但看下结构体B和结构体A的区别在于结构体B定义了一个构造函数,并使用了成员初始化列表来初始化B的两个变量,因此列表初始化在这里就不起作用了,b采用的是构造函数的方式来完成变量的初始化工作。

C++11的列表初始化还有一个额外的功能就是可以防止类型收窄,也就是C++98/03中的隐式类型转换,将范围大的转换为范围小的表示,在C++98/03中类型收窄并不会编译出错,而在C++11中,使用列表初始化的类型收窄编译将会报错:

int a = 1.1; //OK
int b{ 1.1 }; //error
 
float f1 = 1e40; //OK
float f2{ 1e40 }; //error
 
const int x = 1024, y = 1;
char c = x; //OK
char d{ x };//error
char e = y;//error
char f{ y };//error

上面例子看出,用C++98/03的方式类型收窄并不会编译报错,但是将会导致一些隐藏的错误,导致出错的时候很难定位,而利用C++11的列表初始化方法定义变量从源头了遏制了类型收窄,使得不恰当的用法就不会用在程序中,避免了某些位置类型的错误,因此建议以后在实际编程中尽可能的使用列表初始化方法定义变量。

1.3 nullptr常量

C++11中新增nullptr常量,用于生成空指针,代替之前使用的NULL和0。目前有3种初始化空指针的方法:

int *p1 = nullptr; 
int *p2 = 0;
int *p3 = NULL; 

使用 nullptr 代替 0 或 NULL,能显著提高代码的清晰度,尤其是和 auto 连用时;还可以避免重载函数调用模糊的问题。尤其是在使用模板函数时,传入0会被推断为int型,与指针类型不匹配会直接报错。

1.4 constexpr变量

将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式;声明为constexpr的变量一定是一个常量,而且必须用常量表达式来初始化,比如说下面的情况则是不正确的:

int t = 10;
constexpr int q = t + 20;  //t不是常量,报错
cout << "q" << q << endl;

需要将t声明为 const 才是正确的。一般来说,如果你认定变量是一个常量表达式,那就把它声明为constexpr类型;

constexpr也可以用于将函数声明为常量函数,需要遵从几项约定:

  • 函数的返回类型以及所有形参的类型都是字面值类型(只能用它的值来称呼它);

  • 函数体中必须有且只有一条return语句(C++14不再做要求);

  • 必须非virtual

    constexpr int func2() {
    return 10;
    }
    int main() {
    int arr[func2() + 10] = {0};
    return 0;
    }
    

    特别的,在类内,如果成员函数标记为 constexpr,则默认其是内联函数;如果变量声明为 constexpr,则默认其是 const

1.5 类型别名声明

使用类型别名可以使复杂的类型名字变得更简单明了,易于理解和使用。现在有两种方法可以用来定义类型别名,一种是 typedef ,另一种则是新标准中的 using

typedef double dnum;
typedef char *pstring;
using dnum2 = double;
using pstring2 = char*;

要特别注意,如果某个类型别名指代的是复合类型或者常量,那么就会产生意想不到的后果,比如:

typedef char *pstring;
const pstring cstr = 0;

我们这里使用pstring定义cstr,想要得到一个const char*,一个指向常量字符的指针,即指针可变,但是指针指向的内容不可变;但是实际上我们得到了一个char* const,一个指向字符的常量指针,即指针内容可变,但是指针不可变。在这里,使用using的效果也一样,要特别注意。

1.6 auto类型指示符

类型推导。auto类型从初始化表达式中推断出变量的数据类型,所以,其定义的变量必须要有初始值。从这个意义上讲,auto并非一种“类型”声明,而是一个类型声明时的“占位符”,编译器在编译时期会将auto替换为变量实际的类型。

auto c = 1, d = 1;    // 正确
auto a = 1, b = 1.01; // 错误

可以添加*、&、&&修饰符,来定义auto类型的指针和引用。

我们也可以使用auto类型来简化一个函数的定义:

int (*test(int a,int n))[5]
{
	return &a[n]; // 返回的是一个行指针
}

auto test(int a[][5],int n)
{
	return &a[n]; // 同上
}

通过auto避免了复杂的类型声明。

1.7 decltype类型指示符

类型推导。decltype实际上有点像auto的反函数, auto可以让你声明一个变量,而decltype则可以从一个变量或表达式中得到其类型。需要注意的是,decltype((variable))这样的用法,可以声明variable的引用,等效decltype(variable)&

int i = 0;
decltype((i)) a; //报错,因为a类型为 int&,必须进行初始化
decltype(i)& b = i; //正确
decltype(i) c; //正确

1.8 右值引用

左值(lvalue)和右值(rvalue)是从c继承过来的概念,在C++11之后,新标准基于这两个概念新增了部分特征(右值引用,用来解决移动和转发语义)。 我们平常使用的引用都是指左值引用。

在C++98中,临时量(术语为右值,因其出现在赋值表达式的右边)可以被传给函数,但只能被接受为const &类型。这样函数便无法区分传给const &的是真实的右值还是常规变量。而且,由于类型为const &,函数也无法改变所传对象的值。C++0x将增加一种名为右值引用的新的引用类型,记作typename &&。这种类型可以被接受为非const值,从而允许改变其值。这种改变将允许某些对象创建转移语义。比如,一个std::vector,就其内部实现而言,是一个C式数组的封装。如果需要创建vector临时量或者从函数中返回vector,那就只能通过创建一个新的vector并拷贝所有存于右值中的数据来存储数据。之后这个临时的vector则会被销毁,同时删除其包含的数据。有了右值引用,一个参数为指向某个vector的右值引用的std::vector的转移构造器就能够简单地将该右值中C式数组的指针复制到新的vector,然后将该右值清空。这里没有数组拷贝,并且销毁被清空的右值也不会销毁保存数据的内存。返回vector的函数现在只需要返回一个std::vector<>&&。如果vector没有转移构造器,那么结果会像以前一样:用std::vector<> &参数调用它的拷贝构造器。如果vector确实具有转移构造器,那么转移构造器就会被调用,从而避免大量的内存分配。

通俗来说,左值是等号左边的量,右值是等号右边的量,一般是将亡值,比如函数的返回值,等等。

关于如何详细准确的区别左值和右值,请参考cpp_reference–值类别

1.9 universal引用(T&&)

(1)T&&的两种含义

  ①右值引用:当T是确定的类型时,T&&为右值引用。如int&& a;

  ②当T存在类型推导(模板)时,T&&为universal引用,表示一个未定的引用类型。如果被右值初始化,则T&&为右值引用。如果被左值初始化,则T&&为左值引用。

(2)引用折叠

  ①由于引用本身不是一个对象,C++标准不允许直接定义引用的引用。如“int& & a = b;”(注意两个&中间有空格,不是int&&)这样的语句是编译不过的。

  ②当类型推导时可能会间接地创建引用的引用,此时必须进行引用折叠。具体折叠规则如下:

    A. X& &、X& &&和X&& &都折叠成类型X&。即凡是有左值引用参与的情况下,最终的类型都会变成左值引用。

    B. 类型X&& &&折叠成X&&。即只有全部为右值引用的情况才会折叠为右值引用。

  ③引用折叠规则暗示我们,可以将任意类型的实参传递给T&&类型的函数模板参数。

(3)注意事项

  ①只有当发生自动类型推导时(如函数模板的类型自动推导或auto关键字),&&才是一个universal引用。当T的类型是确定的类型时,T&&为右值引用。

  ②当使用左值(类型为A)去初始化T&& t时,类型推导为A& &&,折叠会为A&,即t的类型为左值引用。而如果使用右值初始化T&&时,类型推导为A&&,一步到位无须折叠。

  ③universal引用仅仅在T&&下发生,任何一点附加条件都会使之失效,而变成一个普通的右值引用(const T&&被const修饰就成了右值引用)“

1.10 std::move

move是一个右值相关的函数。它可以将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。如图所示是深拷贝和move的区别:

这种移动语义是很有用的,比如我们一个对象中有一些指针资源或者动态数组,在对象的赋值或者拷贝时就不需要拷贝这些资源了。在c++11之前我们的拷贝构造函数和赋值函数可能要这样定义: ​ 假设一个A对象内部有一个资源m_ptr;

A& A::operator=(const A& rhs)
{
// 销毁m_ptr指向的资源
// 复制rhs.m_ptr所指的资源,并使m_ptr指向它
}

同样A的拷贝构造函数也是这样。假设我们这样来用A:

A foo(); // foo是一个返回值为X的函数
A a;
a = foo();

最后一行有如下的操作:

  • 销毁a所持有的资源
  • 复制foo返回的临时对象所拥有的资源
  • 销毁临时对象,释放其资源

  上面的过程是可行的,但是更有效率的办法是直接交换a和临时对象中的资源指针,然后让临时对象的析构函数去销毁a原来拥有的资源。换句话说,当赋值操作符的右边是右值的时候,我们希望赋值操作符被定义成下面这样:

A& A::operator=(const A&& rhs)
{
// 仅仅转移资源的所有者,将资源的拥有者改为被赋值者
}

  这就是所谓的move语义。再看一个例子,假设一个临时容器很大,赋值给另一个容器。

{
std::list< std::string > tokens;//省略初始化...
std::list< std::string > t = tokens;
}
std::list< std::string > tokens;
std::list< std::string > t = std::move(tokens);

  如果不用std::move,拷贝的代价很大,性能较低。使用move几乎没有任何代价,只是转换了资源的所有权。如果一个对象内部有较大的对内存或者动态数组时,很有必要写move语义的拷贝构造函数和赋值函数,避免无谓的深拷贝,以提高性能。

1.11 std::forward

右值引用类型是独立于值的,一个右值引用参数作为函数的形参,在函数内部再转发该参数的时候它已经变成一个左值了,并不是它原来的类型了。因此,我们需要一种方法能按照参数原来的类型转发到另一个函数,这种转发被称为完美转发。所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。C++11中提供了这样的一个函数std::forward,它是为转发而生的,它会按照参数本来的类型来转发出去,不管参数类型是T&&这种未定的引用类型还是明确的左值引用或者右值引用。看看这个cpp_refenrence上的例子:

#include <iostream>
#include <memory>
#include <utility>
#include <array>

struct A {
    A(int&& n) { std::cout << "rvalue overload, n=" << n << "\n"; }
    A(int& n)  { std::cout << "lvalue overload, n=" << n << "\n"; }
};

class B {
public:
    template<class T1, class T2, class T3>
    B(T1&& t1, T2&& t2, T3&& t3) :
        a1_{std::forward<T1>(t1)},
        a2_{std::forward<T2>(t2)},
        a3_{std::forward<T3>(t3)}
    {
    }

private:
    A a1_, a2_, a3_;
};

template<class T, class U>
std::unique_ptr<T> make_unique1(U&& u)
{
    return std::unique_ptr<T>(new T(std::forward<U>(u)));
}

template<class T, class... U>
std::unique_ptr<T> make_unique(U&&... u)
{
    return std::unique_ptr<T>(new T(std::forward<U>(u)...));
}

int main()
{   
    auto p1 = make_unique1<A>(2); // rvalue
    int i = 1;
    auto p2 = make_unique1<A>(i); // lvalue

    std::cout << "B\n";
    auto t = make_unique<B>(2, i, 3);
}
//输出:
rvalue overload, n=2
lvalue overload, n=1
B
rvalue overload, n=2
lvalue overload, n=1
rvalue overload, n=3

如果在B的构造中不使用forward,那么将会调用3次A的左值构造函数。因为参数2和3作为右值引用传入B的构造函数,变成了具名变量t,右值变成了左值,将引起不必要的内存开销。

1.12 除法的舍入规则

新标准中,一律向0取整(直接切除小数部分),例:

double a = 14/5;
cout << a << endl;

输出结果为2,删掉了小数部分。

2.STL

2.1 范围for语句

for(declaration : expression) ​ 其中,expression 部分是一个对象,用于表示一个序列。declaration 部分负责定义一个变量,该变量将用于访问序列中的基础元素。每次迭代,declaration部分的变量会被初始化为expression部分的下一个元素值。

例如:

string str("hello world");
for (auto c : str) {
	cout << c;
}

可以遍历str。也可以遍历vector、map等STL容器。

2.2 cbegin()、cend()

C++11新增的STL成员函数,与之前的begin()、end()对应,返回const类型的迭代器。cbegin()返回指向容器首的const迭代,cend()返回指向容器尾的const迭代器。

2.3 std::tuple

C++11新增的容器,它是通用化的std::pair。pair只能有first和second两个元素,tuple可以将多个元素合并成一组。通常用于让函数返回多个值:

std::tuple<double, char, std::string>

这样就声明了一个元组。我们可以通过std::get、std::tie或结构化绑定(C++17起)获得元组中每个元素的值。

std::tuple<double, char, std::string> get_student(int id)
{
    if (id == 0) return std::make_tuple(3.8, 'A', "Lisa Simpson");
    if (id == 1) return std::make_tuple(2.9, 'C', "Milhouse Van Houten");
    if (id == 2) return std::make_tuple(1.7, 'D', "Ralph Wiggum");
    throw std::invalid_argument("id");
}
 
int main()
{
    auto student0 = get_student(0);
    //使用std::get获得元组中的元素
    //用法:std::get<n>(tuple),n是tuple中元素的位置,从0开始,tuple是元组变量
    std::cout << "ID: 0, "
              << "GPA: " << std::get<0>(student0) << ", "
              << "grade: " << std::get<1>(student0) << ", "
              << "name: " << std::get<2>(student0) << '\n';
 
    double gpa1;
    char grade1;
    std::string name1;
    // 使用std::tie获得元组中的元素
    std::tie(gpa1, grade1, name1) = get_student(1);
    std::cout << "ID: 1, "
              << "GPA: " << gpa1 << ", "
              << "grade: " << grade1 << ", "
              << "name: " << name1 << '\n';
 
    //通过C++17结构化绑定获得元组中的元素
    auto [ gpa2, grade2, name2 ] = get_student(2);
    std::cout << "ID: 2, "
              << "GPA: " << gpa2 << ", "
              << "grade: " << grade2 << ", "
              << "name: " << name2 << '\n';
}

注意,C++17前,函数不能用初始化列表返回tuple:

std::tuple<int, int> foo_tuple() 
{
  return {1, -1};  // C++17 前错误
  return std::make_tuple(1, -1); // 始终有效
}

特别的,这种写法也是错误的:

auto foo_tuple() 
{
  return {1, -1};  // 错误
}

这样没办法推导返回类型。但是可以通过尾置返回类型这样声明:

auto test() -> std::tuple<int, int>
{
	return  { 1, -1 }; // 正确
}

2.5 std::emplace

C++11中,针对顺序容器(如vector、deque、list),新标准引入了三个新成员:emplace_front、emplace和emplace_back,这些操作构造而不是拷贝元素。这些操作分别对应push_front、insert和push_back,允许我们将元素放置在容器头部、一个指定位置之前或容器尾部。

当调用push或insert成员函数时,我们将元素类型的对象传递给它们,这些对象被拷贝到容器中。而当我们调用一个emplace成员函数时,则是将参数传递给元素类型的构造函数。emplace成员使用这些参数在容器管理的内存空间中直接构造元素。

emplace函数的参数根据元素类型而变化,参数必须与元素类型的构造函数相匹配。emplace函数在容器中直接构造元素。传递给emplace函数的参数必须与元素类型的构造函数相匹配。

其它容器中,std::forward_list中的emplace_after、emplace_front函数,std::map/std::multimap中的emplace、emplace_hint函数,std::set/std::multiset中的emplace、emplace_hint,std::stack中的emplace函数,等emplace相似函数操作也均是构造而不是拷贝元素。

emplace相关函数可以减少内存拷贝和移动。在STL中,push_back左值,需要1次拷贝构造;push_back右值(std::move)需要一次移动构造,而且在调用之前还需要先构造元素本身;而使用emplace添加一个元素,这个元素原地构造,不需要触发拷贝构造和移动构造,而且调用形式更加简洁,直接根据参数初始化临时对象的成员,只调用1次构造函数。

int main()
{
    std::map<std::string, std::string> m;
 
    // 使用 pair 的移动构造函数
    m.emplace(std::make_pair(std::string("a"), std::string("a")));
 
    // 使用 pair 的转换移动构造函数
    m.emplace(std::make_pair("b", "abcd"));
 
    // 使用 pair 的模板构造函数
    m.emplace("d", "ddd");
 
    // 使用 pair 的逐片构造函数
    m.emplace(std::piecewise_construct,
              std::forward_as_tuple("c"),
              std::forward_as_tuple(10, 'c'));
    // C++17 起,能使用 m.try_emplace("c", 10, 'c');
 
    for (const auto &p : m) {
        std::cout << p.first << " => " << p.second << '\n';
    }
}

2.6 STL列表初始化及列表返回值

可以通过初始化列表代替STL类型变量做函数返回值,也可以用参数列表初始化STL容器。

vector<int> test()
{
	return { 1,2,3,4,5 };
}

vector<int> a{ 1,2,3,4,5 };
vector<int> a({ 1,2,3,4,5 });
vector<int> a = {1,2,3,4,5};
map<int, int> b{ (1,1),(2,2) };

也可以用参数列表初始化自定义类型(类或结构体):

struct test {
	test(int a,int b) {}
};

int main()
{
	test a{ 1,2 };
	test a(1, 2);
	test a = { 1, 2 };
	return 0;
}

列表初始化实际上是由std::initializer_list<T>完成的。这是C++11及以后的一个模板类。如果我们这样vector<int> a({1,2,3,4,5})初始化一个vector,实际上是将std::initializer_list<double>作为vector构造函数的参数。

现在分析这样的一个列表初始化:

vector<int> a(10);
vector<int> b{10};

要注意,a被初始化了成了一个大小为10的空vector,而b初始化成了一个大小为1的vector,该元素的值是10。即,如果类接受std::initializer_list<T>作为其构造参数,那么语法{}将调用初始化列表的构造函数。假如vector没有初始化列表的构造函数,那么如上的两个语句其效果应该是相同的。涉及列表初始化类型转换的部分,已经在第一章中介绍,这里不再赘述。

2.7 std::swap

C++11起,定义在头文件 中。常量复杂度,交换两个变量的值。除了 array 外,swap不对任何元素进行拷贝、删除或者插入操作,因此可以保证常数时间内完成;swap 只是交换了容器内部数据结构,不会交换元素,因此,除string 外,指向容器的迭代器、引用和指针在 swap 操作后都不会失效。但是,对array的swap,会真正的交换它们的元素。​

2.8 string的数值转换函数

新标准中,引入多个函数实现数值数据和标准库string之间的转换:

函数描述
to_string(val)返回任意算术类型val的字符串
stoi(s, p, b)int类型
stol(s, p, b)long类型
stoul(s, p, b)unsigned long类型
stoll(s, p, b)long long类型
stoull(s, p, b)unsigned long long类型
stof(s, p, b)float类型
stod(s, p, b)double类型
stold(s, p, b)long double类型

其中,s是字符串,p是开始转换的位置,默认是0,b是转换的底,默认是0。如果底是0,会自动检测数值进制:若前缀为0,则底为八进制,若前缀为 0x 或0X ,则底为十六进制,否则底为十进制。

2.9 shrink_to_fit

调用该函数要求 dequevectorstring 退回不需要的内存空间。它是减少 capacity()size()非强制性请求。请求是否达成依赖于实现。若发生重分配,则所有迭代器,包含尾后迭代器,和所有到元素的引用都被非法化。若不发生重分配,则没有迭代器或引用被非法化。

2.10 智能指针

C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

智能指针有三种,分别是shared_ptr、unique_ptr以及weak_ptr。

2.10.1 shared_ptr

shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

  • 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr p4 = new int(1);的写法是错误的

  • 拷贝和赋值。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1,指向后来的对象。

  • get()方法获取原始指针

  • 注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存

  • 注意避免循环引用,shared_ptr的一个最大的陷阱是循环引用,循环,循环引用会导致堆内存无法正确释放,导致内存泄漏。

    #include <iostream>
    #include <memory>
    
    int main() {
        {
            int a = 10;
            std::shared_ptr<int> ptra = std::make_shared<int>(a);
            std::shared_ptr<int> ptra2(ptra); //copy
            std::cout << ptra.use_count() << std::endl;
    
            int b = 20;
            int *pb = &a;
            //std::shared_ptr<int> ptrb = pb;  //error
            std::shared_ptr<int> ptrb = std::make_shared<int>(b);
            ptra2 = ptrb; //assign
            pb = ptrb.get(); //获取原始指针
    
            std::cout << ptra.use_count() << std::endl;
            std::cout << ptrb.use_count() << std::endl;
        }
    }
    
    

    2.10.2 unique_ptr

    unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。

    #include <iostream>
    #include <memory>
    
    int main() {
        {
            std::unique_ptr<int> uptr(new int(10));  //绑定动态对象
            //std::unique_ptr<int> uptr2 = uptr;  //不能賦值
            //std::unique_ptr<int> uptr2(uptr);  //不能拷貝
            std::unique_ptr<int> uptr2 = std::move(uptr); //轉換所有權
            uptr2.release(); //释放所有权
        }
        //超過uptr的作用域,內存釋放
    }
    
    

 

2.10.3 weak_ptr

  weak_ptr是为了配合shared_ptr而引入的一种智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快,表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。

  #include <iostream>
  #include <memory>
  
  int main() {
      {
          std::shared_ptr<int> sh_ptr = std::make_shared<int>(10);
          std::cout << sh_ptr.use_count() << std::endl;
  
          std::weak_ptr<int> wp(sh_ptr);
          std::cout << wp.use_count() << std::endl;
  
          if(!wp.expired()){
              std::shared_ptr<int> sh_ptr2 = wp.lock(); //get another shared_ptr
              *sh_ptr = 100;
              std::cout << wp.use_count() << std::endl;
          }
      }
      //delete memory
  }

2.10.4 循环引用

​ 考虑一个简单的对象建模——家长与子女:a Parent has a Child, a Child knows his/her Parent。在Java 里边很好写,不用担心内存泄漏,也不用担心空悬指针,只要正确初始化myChild 和myParent,那么Java 程序员就不用担心出现访问错误。一个handle 是否有效,只需要判断其是否non null。在C++ 里边就要为资源管理费一番脑筋。如果使用原始指针作为成员,Child和Parent由谁释放?那么如何保证指针的有效性?如何防止出现空悬指针?这些问题是C++面向对象编程麻烦的问题,现在可以借助智能指针把对象语义(pointer)转变为值(value)语义,shared_ptr轻松解决生命周期的问题,不必担心空悬指针。但是这个模型存在循环引用的问题,注意其中一个指针应该为weak_ptr。

首先考虑一下,采用原始指针如何实现这个设计:

  #include <iostream>
  #include <memory>
  
  class Child;
  class Parent;
  
  class Parent {
  private:
      Child* myChild;
  public:
      void setChild(Child* ch) {
          this->myChild = ch;
      }
  
      void doSomething() {
          if (this->myChild) {
  
          }
      }
  
      ~Parent() {
          delete myChild;
      }
  };
  
  class Child {
  private:
      Parent* myParent;
  public:
      void setPartent(Parent* p) {
          this->myParent = p;
      }
      void doSomething() {
          if (this->myParent) {
  
          }
      }
      ~Child() {
          delete myParent;
      }
  };
  
  int main() {
      {
          Parent* p = new Parent;
          Child* c =  new Child;
          p->setChild(c);
          c->setPartent(p);
          delete c;  //only delete one
      }
      return 0;
  }

​ 无论是delete c还是delete p,都只需要delete一次,且在delete后,没有被delete的指针变成了悬空指针,在编程中容易发生错误。

​ 现在考虑用智能指针实现这个设计。如果在parent和child中都使用智能指针,则会产生循环引用,从而导致智能指针无法正确析构对象,结果就是内存泄漏:

  #include <iostream>
  #include <memory>
  
  class Child;
  class Parent;
  
  class Parent {
  private:
      std::shared_ptr<Child> ChildPtr;
  public:
      void setChild(std::shared_ptr<Child> child) {
          this->ChildPtr = child;
      }
  
      void doSomething() {
          if (this->ChildPtr.use_count()) {
  
          }
      }
  
      ~Parent() {
      }
  };
  
  class Child {
  private:
      std::shared_ptr<Parent> ParentPtr;
  public:
      void setPartent(std::shared_ptr<Parent> parent) {
          this->ParentPtr = parent;
      }
      void doSomething() {
          if (this->ParentPtr.use_count()) {
  
          }
      }
      ~Child() {
      }
  };
  
  int main() {
      std::weak_ptr<Parent> wpp;
      std::weak_ptr<Child> wpc;
      {
          std::shared_ptr<Parent> p(new Parent);
          std::shared_ptr<Child> c(new Child);
          p->setChild(c);
          c->setPartent(p);
          wpp = p;
          wpc = c;
          std::cout << p.use_count() << std::endl; // 2
          std::cout << c.use_count() << std::endl; // 2
      }
      std::cout << wpp.use_count() << std::endl;  // 1
      std::cout << wpc.use_count() << std::endl;  // 1
      return 0;
  }

​ 在这里,创建p,使得p的引用计数为1,再在子类中设置父类为p,使得p的引用计数为2,子类指针c也一样为2。在离开了作用域以后,引用计数减一为1,我们可以用weak_ptr观测引用计数得到这个结果。最后导致对象无法被析构,产生内存泄漏。

​ 正确的使用方式应该是这样:

  #include <iostream>
  #include <memory>
  
  class Child;
  class Parent;
  
  class Parent {
  private:
      //std::shared_ptr<Child> ChildPtr;
      std::weak_ptr<Child> ChildPtr;
  public:
      void setChild(std::shared_ptr<Child> child) {
          this->ChildPtr = child;
      }
  
      void doSomething() {
          //new shared_ptr
          if (this->ChildPtr.lock()) {
  
          }
      }
  
      ~Parent() {
      }
  };
  
  class Child {
  private:
      std::shared_ptr<Parent> ParentPtr;
  public:
      void setPartent(std::shared_ptr<Parent> parent) {
          this->ParentPtr = parent;
      }
      void doSomething() {
          if (this->ParentPtr.use_count()) {
  
          }
      }
      ~Child() {
      }
  };
  
  int main() {
      std::weak_ptr<Parent> wpp;
      std::weak_ptr<Child> wpc;
      {
          std::shared_ptr<Parent> p(new Parent);
          std::shared_ptr<Child> c(new Child);
          p->setChild(c);
          c->setPartent(p);
          wpp = p;
          wpc = c;
          std::cout << p.use_count() << std::endl; // 2
          std::cout << c.use_count() << std::endl; // 1
      }
      std::cout << wpp.use_count() << std::endl;  // 0
      std::cout << wpc.use_count() << std::endl;  // 0
      return 0;
  }

​ 使用一个weak_ptr来进行引用,这样c的引用计数不会为2。离开作用域以后c被析构,c控制的指向p的智能指针也被析构,这样p的引用计数一次性减2,c和p都可以正常析构,不会产生内存泄漏。

​ 正确使用智能指针,可以帮助我们减少开发中许多不必要的麻烦,增强安全性和便利性。

3.函数

3.1匿名函数lambda

Lambda表达式完整的声明格式如下:

[capture list](parameter list) mutable exception -> return type { function body }

各项具体含义如下:

  1. capture list:捕获外部变量列表

  2. params list:形参列表

  3. mutable指示符:用来说用是否可以修改捕获的变量

  4. exception:异常设定

  5. return type:返回类型

  6. function body:函数体

    此外,我们还可以省略其中的某些成分来声明“不完整”的Lambda表达式,常见的有以下几种:

序号格式
1[capture list] (params list) -> return type {function body}
2[capture list] (params list) {function body}
3[capture list] {function body}

其中:

  • 格式1声明了const类型的表达式,这种类型的表达式不能修改捕获列表中的值。

  • 格式2省略了返回值类型,但编译器可以根据以下规则推断出Lambda表达式的返回类型: (1):如果function body中存在return语句,则该Lambda表达式的返回类型由return语句的返回类型确定; (2):如果function body中没有return语句,则返回值为void类型。

  • 格式3中省略了参数列表,类似普通函数中的无参函数。

    以下是lambda表达式的一个例子:

    #include <iostream>
    #include <vector>
    #include <algorithm>
    using namespace std;
    
    bool cmp(int a, int b)
    {
        return  a < b;
    }
    
    int main()
    {
        vector<int> myvec{ 3, 2, 5, 7, 3, 2 };
        vector<int> lbvec(myvec);
    
        sort(myvec.begin(), myvec.end(), cmp); // 旧式做法
        cout << "predicate function:" << endl;
        for (int it : myvec)
            cout << it << ' ';
        cout << endl;
    
        sort(lbvec.begin(), lbvec.end(), [](int a, int b) -> bool { return a < b; });   // Lambda表达式
        cout << "lambda expression:" << endl;
        for (int it : lbvec)
            cout << it << ' ';
    }
    
    

    在C++11之前,我们使用STL的sort函数,需要提供一个谓词函数。如果使用C++11的Lambda表达式,我们只需要传入一个匿名函数即可,方便简洁,而且代码的可读性也比旧式的做法好多了。

    3.1.1 捕获外部变量

    Lambda表达式可以使用其可见范围内的外部变量,但必须明确声明(明确声明哪些外部变量可以被该Lambda表达式使用)。那么,在哪里指定这些外部变量呢?Lambda表达式通过在最前面的方括号[]来明确指明其内部可以访问的外部变量,这一过程也称过Lambda表达式“捕获”了外部变量。

    我们通过一个例子来直观地说明一下:

    #include <iostream>
    using namespace std;
    
    int main()
    {
        int a = 123;
        auto f = [a] { cout << a << endl; }; 
        f(); // 输出:123
    
        //或通过“函数体”后面的‘()’传入参数
        auto x = [](int a){cout << a << endl;}(123); 
    }
    
    

    上面这个例子先声明了一个整型变量a,然后再创建Lambda表达式,该表达式“捕获”了a变量,这样在Lambda表达式函数体中就可以获得该变量的值。

    类似参数传递方式(值传递、引入传递、指针传递),在Lambda表达式中,外部变量的捕获方式也有值捕获、引用捕获、隐式捕获。其中,隐式捕获指示编译器推断需要捕获的变量列表:

    捕获形式说明
    []不捕获任何外部变量
    [变量名, …]默认以值得形式捕获指定的多个外部变量(用逗号分隔),如果引用捕获,需要显示声明(使用&说明符)
    [this]以值的形式捕获this指针
    [=]隐式捕获,以值的形式捕获函数体用到的外部变量
    [&]隐式捕获,以引用形式捕获函数体用到的外部变量
    [=, &x]变量x以引用形式捕获,其余变量以传值形式隐式捕获
    [&, x]变量x以值的形式捕获,其余变量以引用形式隐式捕获

3.1.2 修改捕获变量

在Lambda表达式中,如果以传值方式捕获外部变量,则函数体中不能修改该外部变量,否则会引发编译错误。我们可以使用mutable关键字,该关键字用以说明表达式体内的代码可以修改值捕获的变量:

int main()
{
    int a = 123;
    auto f = [a]()mutable { cout << ++a; }; // 不会报错
    cout << a << endl; // 输出:123
    f(); // 输出:124
}

在Lambda表达式中传递参数还有一些限制,主要有以下几点:

  1. 参数列表中不能有默认参数
  2. 不支持可变参数
  3. 所有参数必须有参数名

3.2 尾置返回类型

一般用于和auto、decltype一起简化函数定义:

auto func(int) -> int(*)[10]
{
	...
}

3.3 std::function

std::function是一个函数包装器模板,最早来自boost库,对应其boost::function函数包装器。在c++11中,将boost::function纳入标准库中。该函数包装器模板能包装任何类型的可调用元素(callable element),例如普通函数和函数对象。包装器对象可以进行拷贝,并且包装器类型仅仅只依赖于其调用特征(call signature),而不依赖于可调用元素自身的类型。

一个std::function类型对象实例可以包装下列这几种可调用元素类型:函数、函数指针、类成员函数指针或任意类型的函数对象(例如定义了operator()操作并拥有函数闭包)。std::function对象可被拷贝和转移,并且可以使用指定的调用特征来直接调用目标元素。当std::function对象未包裹任何实际的可调用元素,调用该std::function对象将抛出std::bad_function_call异常。

3.3.1 包装普通函数

#include <iostream>
#include <functional>
using namespace std;

int g_Minus(int i, int j)
{
    return i - j;
}

int main()
{
    function<int(int, int)> f = g_Minus;
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.2 包装模板函数

#include <iostream>
#include <functional>
using namespace std;

template <class T>
T g_Minus(T i, T j)
{
    return i - j;
}

int main()
{
    function<int(int, int)> f = g_Minus<int>;
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.3 包装lambda表达式

#include <iostream>
#include <functional>
using namespace std;

auto g_Minus = [](int i, int j){ return i - j; };

int main()
{
    function<int(int, int)> f = g_Minus;
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.4 包装函数对象

非模板类型:

#include <iostream>
#include <functional>
using namespace std;

struct Minus
{
    int operator() (int i, int j)
    {
        return i - j;
    }
};

int main()
{
    function<int(int, int)> f = Minus();
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

模板类型:

#include <iostream>
#include <functional>
using namespace std;

template <class T>
struct Minus
{
    T operator() (T i, T j)
    {
        return i - j;
    }
};

int main()
{
    function<int(int, int)> f = Minus<int>();
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.5 包装类静态成员函数

非模板类型:

#include <iostream>
#include <functional>
using namespace std;

class Math
{
public:
    static int Minus(int i, int j)
    {
        return i - j;
    }
};

int main()
{
    function<int(int, int)> f = &Math::Minus;
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

模板类型:

#include <iostream>
#include <functional>
using namespace std;

class Math
{
public:
    template <class T>
    static T Minus(T i, T j)
    {
        return i - j;
    }
};

int main()
{
    function<int(int, int)> f = &Math::Minus<int>;
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.7 包装类对象成员函数

非模板类型:

#include <iostream>
#include <functional>
using namespace std;

class Math
{
public:
    int Minus(int i, int j)
    {
        return i - j;
    }
};

int main()
{
    Math m;
    function<int(int, int)> f = bind(&Math::Minus, &m, placeholders::_1, placeholders::_2);
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

模板类型:

#include <iostream>
#include <functional>
using namespace std;

class Math
{
public:
    template <class T>
    T Minus(T i, T j)
    {
        return i - j;
    }
};

int main()
{
    Math m;
    function<int(int, int)> f = bind(&Math::Minus<int>, &m, placeholders::_1, placeholders::_2);
    cout << f(1, 2) << endl;                                            // -1
    return 1;
}

3.3.8 std::bind

bind原先是boost中的方法,在C++11以前已经被广泛使用,从C++11开始被纳入std,定义在头文件、<functional>中。它的作用是生成一个函数f的转发调用包装器,调用此包装器等价于以一些绑定到 args 的参数调用 f 。有点类似函数式编程。例子如下:

#include <random>
#include <iostream>
#include <memory>
#include <functional>
 
void f(int n1, int n2, int n3, const int& n4, int n5)
{
    std::cout << n1 << ' ' << n2 << ' ' << n3 << ' ' << n4 << ' ' << n5 << '\n';
}
 
int g(int n1)
{
    return n1;
}
 
struct Foo {
    void print_sum(int n1, int n2)
    {
        std::cout << n1+n2 << '\n';
    }
    int data = 10;
};
 
int main()
{
    using namespace std::placeholders;  // 对于 _1, _2, _3...
 
    // 演示参数重排序和按引用传递
    int n = 7;
    // ( _1 与 _2 来自 std::placeholders ,并表示将来会传递给 f1 的参数)
    auto f1 = std::bind(f, _2, _1, 42, std::cref(n), n);
    n = 10;
    f1(1, 2, 1001); // 1 为 _1 所绑定, 2 为 _2 所绑定,不使用 1001
                    // 进行到 f(2, 1, 42, n, 7) 的调用
 
    // 嵌套 bind 子表达式共享占位符
    auto f2 = std::bind(f, _3, std::bind(g, _3), _3, 4, 5);
    f2(10, 11, 12); // 进行到 f(12, g(12), 12, 4, 5); 的调用
 
    // 常见使用情况:以分布绑定 RNG
    std::default_random_engine e;
    std::uniform_int_distribution<> d(0, 10);
    std::function<int()> rnd = std::bind(d, e); // e 的一个副本存储于 rnd
    for(int n=0; n<10; ++n)
        std::cout << rnd() << ' ';
    std::cout << '\n';
 
    // 绑定指向成员函数指针
    Foo foo;
    auto f3 = std::bind(&Foo::print_sum, &foo, 95, _1);
    f3(5);
 
    // 绑定指向数据成员指针
    auto f4 = std::bind(&Foo::data, _1);
    std::cout << f4(foo) << '\n';
 
    // 智能指针亦能用于调用被引用对象的成员
    std::cout << f4(std::make_shared<Foo>(foo)) << '\n'
              << f4(std::make_unique<Foo>(foo)) << '\n';
}

实际上在使用中,匿名函数lambda也可以取到和bind相同的效果,在写法上也比bind方便,看起来也更清晰,例如上面例子的auto f1 = std::bind(f, _2, _1, 42, std::cref(n), n);,如果用lambda来写就是auto f1 = [&n](int a, int b){f(b,a,42,n,7);};,可以看到用lambda实现起来更方便,也不需要cref这种帮助函数。调用它使用f1(1,2);得到的运行结果与使用bind完全相同。注意使用lambda做函数包装时,传值和传引用的区别:如果这里对n传值,那么下面的所有调用里面n的值都是定义的时候传入的10,相当于f(b,a,42,10,7);如果是传引用,后面n=7的赋值会影响到调用f1的结果。

lambda的简洁性,如果以3.3.8为例会更明显。使用bind的写法是这样的:auto f = bind(&Math::Minus<int>, &m, placeholders::_1, placeholders::_2);,使用lambda的写法是这样的:auto f = [&m](int a, int b){ return m.Minus(a,b);};。显然使用lambda包装要自然简洁得多。

4.线程支持库

C++11开始,提供了包含线程、互斥、条件变量和期货的内建支持。

###4.1 线程

std::thread,用于定义一个线程,用法如下:

auto test(int a, int b)
{
	return a + b;
}

int main()
{
	thread t(test,1,2); // test(1,2)
	t.join();			// 阻塞当前线程,直到子线程返回
    //t.detach();		// 从 thread 对象分离执行的线程,允许执行独立地持续。
    					// 一旦线程退出,则释放所有分配的资源。
    					// 调用 detach 后,t不再占有任何线程。
	return 0;
}

一个线程用函数和参数构造,然后这个线程就会根据参数去执行这个函数。要注意,thread是无法获取执行的函数的返回值的,它会忽略顶层函数的返回值。如过要获取返回值,可以通过共享变量或std::promise。

特别的,thread是支持swap的。你可以通过swap来交换两个thread对象所管理的线程句柄。

此外,thread还有获取线程ID的方法get_id()、休眠线程的方法sleep_for()、调度线程的方法yield()等。有关线程库的更多信息,请参照std::thread

###4.2 互斥

互斥算法避免多个线程同时访问共享资源。这会避免数据竞争,并提供线程间的同步支持。主要用于对发生竞争的变量加锁,这也导致了性能上的损失。

这里我只介绍以下用的最多的mutex与recursive_mutex,std::lock_guard与std::unique_lock。

此示例展示 mutex 能如何用于在保护共享于二个线程间的std::map:

	#include <iostream>
#include <map>
#include <string>
#include <chrono>
#include <thread>
#include <mutex>
 
std::map<std::string, std::string> g_pages;
std::mutex g_pages_mutex;
 
void save_page(const std::string &url)
{
    // 模拟长页面读取
    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::string result = "fake content";
 
    std::lock_guard<std::mutex> guard(g_pages_mutex);
    g_pages[url] = result;
}
 
int main() 
{
    std::thread t1(save_page, "http://foo");
    std::thread t2(save_page, "http://bar");
    t1.join();
    t2.join();
 
    // 现在访问g_pages是安全的,因为线程t1/t2生命周期已结束
    for (const auto &pair : g_pages) {
        std::cout << pair.first << " => " << pair.second << '\n';
    }
}

使用mutex声明并定义一个锁变量,然后用lock_guard获得锁的所有权。创建 lock_guard 对象时,它试图接收给定互斥的所有权。控制离开创建 lock_guard 对象的作用域时,销毁 lock_guard 并释放互斥。即函数返回后,lock_guard对象从栈中销毁,同时自动解锁,使用起来十分方便。

有的时候我们对于递归的函数也有上锁的需求,这个时候如果使用mutex则会造成死锁。我们可以使用递归锁recursive_mutex,它允许同一个线程多次上锁,但是解锁时,解锁的次数要和上锁的次数一致。其用法与mutex是一样的,这样就可以为递归函数上锁而不导致死锁。

至于unique_lock,它具有lock_guard的所有功能,但比其更加灵活。unique_lock对象以独占所有权的方式(unique owership)管理mutex对象的上锁和解锁操作,即在unique_lock对象的声明周期内,它所管理的锁对象会一直保持上锁状态;而unique_lock的生命周期结束之后,它所管理的锁对象会被解锁。与lock_guard只能给一个互斥量上锁不同,unique_lock可以同时锁定多个互斥量,这避免了多道加锁时的资源死锁问题。它的缺点是相比lock_guard空间和性能开销都要大一些。

这是互斥访问自定义类型的示例代码:

#include <mutex>
#include <thread>
#include <chrono>
 
struct Box {
    explicit Box(int num) : num_things{num} {}
 
    int num_things;
    std::mutex m;
};
 
void transfer(Box &from, Box &to, int num)
{
    // 仍未实际取锁
    std::unique_lock<std::mutex> lock1(from.m, std::defer_lock);
    std::unique_lock<std::mutex> lock2(to.m, std::defer_lock);
 
    // 锁两个 unique_lock 而不死锁
    std::lock(lock1, lock2);
 
    from.num_things -= num;
    to.num_things += num;
 
    // 'from.m' 与 'to.m' 互斥解锁于 'unique_lock' 析构函数
}
 
int main()
{
    Box acc1(100);
    Box acc2(50);
 
    std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);
    std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 5);
 
    t1.join();
    t2.join();
}

注意我们这里使用了std::defer_lock选项来初始化unique_lock,这样并不会立即加锁。

实际上有3种锁策略:

  1. `defer_lock不获得互斥的所有权
  2. try_to_lock尝试获得互斥的所有权而不阻塞
  3. adopt_lock假设调用方线程已拥有互斥的所有权

如果没有定义任何锁策略,那么unique_lock也会像lock_guard一样立即上锁。除此之外,unique_lock也支持在其对象的生命周期内调用std::unlock主动解锁,还可以多次调用std::lockstd::unlock反复加解锁。这些功能都是lock_guard不具备的。​

4.3 条件变量

std::condition_variable,线程间同步的一种方式,能用于阻塞一个线程,或同时阻塞多个线程,直至另一线程修改共享变量并通知 condition_variable ,配合mutex使用,例子如下:

#include <iostream>
#include <string>
#include <thread>
#include <mutex>
#include <condition_variable>
 
std::mutex m;
std::condition_variable cv;
std::string data;
bool ready = false;
bool processed = false;
 
void worker_thread()
{
    // 等待直至 main() 发送数据
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, []{return ready;});
 
    // 等待后,我们占有锁。
    std::cout << "Worker thread is processing data\n";
    data += " after processing";
 
    // 发送数据回 main()
    processed = true;
    std::cout << "Worker thread signals data processing completed\n";
 
    // 通知前完成手动解锁,以避免等待线程才被唤醒就阻塞(细节见 notify_one )
    lk.unlock();
    cv.notify_one();
}
 
int main()
{
    std::thread worker(worker_thread);
 
    data = "Example data";
    // 发送数据到 worker 线程
    {
        std::lock_guard<std::mutex> lk(m);
        ready = true;
        std::cout << "main() signals data ready for processing\n";
    }
    cv.notify_one();
 
    // 等候 worker
    {
        std::unique_lock<std::mutex> lk(m);
        cv.wait(lk, []{return processed;});
    }
    std::cout << "Back in main(), data = " << data << '\n';
 
    worker.join();
}

注意,每执行一次notify_one(),就会有一个wait()被唤醒。这个程序的工作流程是这样的。如果子线程先得到锁m,那么在cv.wait()的回调中因为ready的值为false被阻塞,之后主线程便可以获得锁m,设子线程的标志reday为true,然后通知cv上的一个等待线程(也可以通过notify_all()通知所有线程)。之后主线程获得锁,并调用cv.wait(),同样由于process是false而阻塞。主线程阻塞后,子线程获得锁并继续执行,修改process标志,解锁,并通知等待线程。此时主线程的cv.wait()由于process=true而不再被阻塞,至此程序执行完毕。如果主线程先获得锁m,那么在执行完std::cout << "main() signals data ready for processing\n";后,主线程解锁,子线程获得锁,但被wait阻塞。主线程通知以后,子线程不再阻塞,主线程锁m失败,被阻塞。子线程执行解锁后,子线程完成任务,主线程继续执行,由于process已经被子线程修改,主线程获得锁后没有在cv.wait()阻塞,之后也能正常运行至结束。可见无论谁先获得锁,结果都是一样的。

因此无论执行顺序如何,无论是谁先获得锁,输出都是:

main() signals data ready for processing
Worker thread is processing data
Worker thread signals data processing completed
Back in main(), data = Example data after processing

要注意,使用条件变量时,线程上锁需要使用unique_lock,不能使用lock_guard。

如果进程间需要同步,条件变量仍然是最合适的方式,它比进行循环判断效率更高,不会浪费CPU时间。我们还可以通过条件变量的思路来获得线程执行函数的返回值,只是有些麻烦。接下来我会介绍用std::promise获取返回值的方法。

4.4 期货

这里只介绍std::promise与std::future,用于获取一个线程的返回值,用例如下:

#include<iostream>    //std::cout std::endl
#include<thread>      //std::thread
#include<future>      //std::future std::promise
#include<utility>     //std::ref
#include<chrono>      //std::chrono::seconds

void initiazer(std::promise<int> &promiseObj){
    std::cout << "Inside thread: " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(1));
    promiseObj.set_value(35);
}

int main(){
    std::promise<int> promiseObj;
    std::future<int> futureObj = promiseObj.get_future();
    std::thread th(initiazer, std::ref(promiseObj));
    
    std::cout << futureObj.get() << std::endl;

    th.join();
    return 0;
}

这样我们就可以获取th的返回值。如果主线程已经运行到get(),而子线程还没有set_value(),那么主线程就会被阻塞。这样获取返回值,比使用条件变量+互斥量要更简洁高效。

5.原子操作库

在多线程开发中,为了确保数据安全性,经常需要对数据进行加锁、解锁处理。C++11中添加了原子操作库,实现无锁并发编程。涉及同一对象的每个原子操作,相对于任何其他原子操作是不可分的。原子对象不具有数据竞争,如果我们在多个线程中对这些类型的共享资源进行操作,编译器将保证这些操作都是原子性的,也就是说,确保任意时刻只有一个线程对这个资源进行访问,编译器将保证,多个线程访问这个共享资源的正确性。原子操作库定义在头文件<atomic>中。

###5.1 支持类型

atomic是一个结构体模板,可以用各种内置类型进行实例化。原子操作库在C++11中支持类型如下:

类型别名定义
std::atomic_boolstd::atomic<bool>
std::atomic_charstd::atomic<char>
std::atomic_scharstd::atomic<signed char>
std::atomic_ucharstd::atomic<unsigned char>
std::atomic_shortstd::atomic<short>
std::atomic_ushortstd::atomic<unsigned short>
std::atomic_intstd::atomic<int>
std::atomic_uintstd::atomic<unsigned int>
std::atomic_longstd::atomic<long>
std::atomic_ulongstd::atomic<unsigned long>
std::atomic_llongstd::atomic<long long>
std::atomic_ullongstd::atomic<unsigned long long>
std::atomic_char16_tstd::atomic<char16_t>
std::atomic_char32_tstd::atomic<char32_t>
std::atomic_wchar_tstd::atomic<wchar_t>
std::atomic_int8_tstd::atomic<std::int8_t>
std::atomic_uint8_tstd::atomic<std::uint8_t>
std::atomic_int16_tstd::atomic<std::int16_t>
std::atomic_uint16_tstd::atomic<std::uint16_t>
std::atomic_int32_tstd::atomic<std::int32_t>
std::atomic_uint32_tstd::atomic<std::uint32_t>
std::atomic_int64_tstd::atomic<std::int64_t>
std::atomic_uint64_tstd::atomic<std::uint64_t>
std::atomic_int_least8_tstd::atomic<std::int_least8_t>
std::atomic_uint_least8_tstd::atomic<std::uint_least8_t>
std::atomic_int_least16_tstd::atomic<std::int_least16_t>
std::atomic_uint_least16_tstd::atomic<std::uint_least16_t>
std::atomic_int_least32_tstd::atomic<std::int_least32_t]>
std::atomic_uint_least32_tstd::atomic<std::uint_least32_t>
std::atomic_int_least64_tstd::atomic<std::int_least64_t>
std::atomic_uint_least64_tstd::atomic<std::uint_least64_t>
std::atomic_int_fast8_tstd::atomic<std::int_fast8_t>
std::atomic_uint_fast8_tstd::atomic<std::uint_fast8_t>
std::atomic_int_fast16_tstd::atomic<std::int_fast16_t>
std::atomic_uint_fast16_tstd::atomic<std::uint_fast16_t>
std::atomic_int_fast32_tstd::atomic<std::int_fast32_t>
std::atomic_uint_fast32_tstd::atomic<std::uint_fast32_t>
std::atomic_int_fast64_tstd::atomic<std::int_fast64_t>
std::atomic_uint_fast64_tstd::atomic<std::uint_fast64_t>
std::atomic_intptr_tstd::atomic<std::intptr_t>
std::atomic_uintptr_tstd::atomic<std::uintptr_t>
std::atomic_size_tstd::atomic<std::size_t>
std::atomic_ptrdiff_tstd::atomic<std::ptrdiff_t>
std::atomic_intmax_tstd::atomic<std::intmax_t>
std::atomic_uintmax_tstd::atomic<std::uintmax_t>

注意: std::atomic_int*N*_tstd::atomic_uint*N*_tstd::atomic_intptr_tatomic_uintptr_t 分别若且唯若定义了 std::int*N*_tstd::uint*N*_tstd::intptr_tstd::uintptr_t 才有定义。

上表来自<atomic>头文件中的定义。

此外,atomic还支持所有的指针类型std::atomic<T*>,且自C++20起,原子操作库为 std::shared_ptr 和 std::weak_ptr 提供部分特化std::atomic<std::shared_ptr<T>> 和 std::atomic<std::weak_ptr<T>> 。细节见 std::atomicstd::atomic 。atomic模板还支持浮点类型float 、 double 和 long double。

###5.2 Trivially Copyable

这是C++的具名要求,翻译过来就是“平凡可复制”。atomic模板要求类型T是可平凡复制的。

可平凡复制要求:

  • 每个复制构造函数为平凡或被删除
  • 每个移动构造函数为平凡或被删除
  • 每个复制赋值运算符为平凡或被删除
  • 每个移动赋值运算符平凡或被删除
  • 至少一个复制构造函数、移动构造函数、复制赋值运算符或移动赋值运算符未被删除
  • 平凡而未被删除的析构函数

复制/移动构造函数和复制/移动赋值运算符的平凡意味着:

  • 它不是用户提供的(即它是隐式定义或设为默认的);
  • T 无虚成员函数;
  • T 无虚基类;
  • T 每个基类选择的复制/移动构造函数、复制/移动赋值运算符是平凡的;
  • 为每个 T 类类型(或类类型数组)非静态成员选择的复制/移动构造函数、复制/移动赋值运算符是平凡的;

由此可见,标量类型和可平凡复制对象的数组,还有这些类型的 const 限定(但非 volatile限定)版本,也是可平凡复制的。由于这些限制,一般atomic模板只能对内置类型,也就是我们上面提到过的类型进行实例化,也可以对自定义简单结构体实例化。显然,面向对象编程离不开虚函数,我们自己编写的对象也很难使用默认的构造函数和析构函数,因此我们的自定义类对象很少有可以使用atomic模板实例化的。atomic一般也就是使用在内置类型上,避免加锁。

5.3 使用

使用原子变量还是很方便的,与使用普通变量没有区别,以下是一段示例代码:

atomic_long total(0);
 
void click()
{
    for(int i=0; i<1000000;++i)
    {
        // 仅仅是数据类型的不同而以,对其的访问形式与普通数据类型的资源并无区别
        total += 1;
    }
}

此外,也像普通内置类型一样支持自增自减、与、或及异或的操作。

5.4 性能

根据我在网上查找到的一些资料,原子操作库相比线程支持库中可以实现同样功能的互斥量,快了70%以上。但mutex不受变量类型限制,功能上要更强。

6.模版与泛型编程

所谓泛型编程就是以独立于任何特定类型的方式编写代码。泛型编程与面向对象编程一样,都依赖于某种形式的多态性。面向对象编程中的多态性在运行时应用于存在继承关系的类。我们能够编写使用这些类的代码,忽略基类与派生类之间类型上的差异。

在泛型编程中,我们所编写的类和函数能够多态地用于跨越编译时不相关的类型。一个类或一个函数可以用来操纵多种类型的对象。面向对象编程所依赖的多态性称为运行时多态性,泛型编程所依赖的多态性称为编译时多态性或参数式多态性。模板是泛型编程的基础。模板是创建类或函数的蓝图或公式。

6.1 类模板

定义一个类模板的语法如下:

template < parameter-list > class-declaration	(1)	
export template < parameter-list > class-declaration	(2)	(C++11 前)

类模板自身不是类型、对象或任何其他实体。不会从从仅含模板定义的源文件生成任何代码。必须实例化模板以令任何代码出现:必须提供模板实参,使得编译器能生成实际的类(或从函数模板生成函数)。

类模板的实例化有两种方式,显式实例化和隐式实例化。

6.1.1 显式实例化

显式实例化定义强制实例化其所指代的 class 、 struct 或 union 。它可以出现在程序中模板定义后的任何位置。而对于给定的实参列表,只允许它在整个程序中出现一次。

namespace N {
  template<class T> class Y { void mf() { } }; // 模板定义
}
// template class Y<int>; // 错误:类模板 Y 在全局命名空间不可见
using N::Y;
// template class Y<int>; // 错误:显式实例化在模板的命名空间外
template class N::Y<char*>;      // OK :显式实例化
template void N::Y<double>::mf(); // OK :显式实例化

类、函数、变量和成员模板特化能从其模板显式实例化。成员函数、成员类和类模板的静态数据成员能从其成员定义显式实例化。若同一组模板实参的显式特化出现于显式实例化之前,则显式实例化无效果。

特别注意,若以显式实例化定义显式实例化函数模板、变量模板、成员函数模板或类模板的成员函数或静态数据成员,则模板定义必须存在于同一翻译单元中。

在有多个cpp文件的情况下,一般来说我们需要将模板的定义和声明放在头文件中,然后让所有的cpp文件include这个头文件,然后在cpp中隐式实例化模板。通过显式实例化,我们只需要在头文件中声明模板,然后在某个cpp中定义模板并显式实例化,其他的cpp文件就可以直接使用显式实例化好的模板。这就是"模版声明实现分离"。

6.1.2 隐式实例化

在要求完整定义的类型的语境中,或当类型的完整性影响代码,而尚未显式实例化此特定类型时,出现隐式实例化。例如在构造此类型的对象时,但非在构造指向此类型的指针时。

这适用于类模板的成员:除非在程序中使用该成员,否则不实例化它,并且不要求定义:

template<class T> struct Z {
    void f() {}
    void g(); // 决不定义
}; // 模板定义
template struct Z<double>; // 显式实例化 Z<double>
Z<int> a; // 隐式实例化 Z<int>
Z<char>* p; // 此处不实例化任何内容
p->f(); // 隐式实例化 Z<char> 而 Z<char>::f() 出现于此。
// 决不需要且决不实例化 Z<char>::g() :不必定义它

若已经声明但未定义类模板,则实例化在实例化点产生不完整类类型:

template<class T> class X; // 声明,非定义
X<char> ch;                // 错误:不完整类型 X<char>

6.2 函数模板

函数模板定义一族函数,可以是成员函数。

其定义如下:

template < parameter-list > function-declaration	(1)	
template < parameter-list > requires constraint function-declaration	(2)	(C++20 起)
function-declaration-with-placeholders	(3)	(概念 TS)
export template < parameter-list > function-declaration	(4)	(C++11 前)

6.2.1 显式实例化

函数模板的显式实例化有多种语法。函数模板特化或成员函数模板特化的显式实例化中,尾随的模板实参可以保留未指定,若它能从函数参数推导:

template<typename T>
void f(T s)
{
    std::cout << s << '\n';
}
 
template void f<double>(double); // 实例化 f<double>(double)
template void f<>(char); // 实例化 f<char>(char) ,推导出模板实参
template void f(int); // 实例化 f<int>(int) ,推导出模板实参

有默认参数的函数模板的显式实例化定义不使用该参数,且不会试图实例化之:

char* p = 0;
template<class T> T g(T x = &p) { return x; }
template int g<int>(int);   // OK 即使 &p 不是 int 。

####6.2.2 隐式实例化

代码在要求函数定义存在的语境中指涉函数,且此特定函数未被显式实例化时,隐式实例化发生。若模板实参列表能从语境推导,则不必提供它。

#include <iostream>
 
template<typename T>
void f(T s)
{
    std::cout << s << '\n';
}
 
int main()
{
    f<double>(1); // 实例化并调用 f<double>(double)
    f<>('a'); // 实例化并调用 f<char>(char)
    f(7); // 实例化并调用 f<int>(int)
    void (*ptr)(std::string) = f; // 实例化 f<string>(string)
}

6.2.3 实参推导

为实例化函数模板,必须知道每个模板实参,但并非必须指定每个模板实参。在可能时,编译器会从函数实参推导缺失的模板实参。这发生于尝试函数调用时及取函数模板的地址时。

template<typename To, typename From> To convert(From f);
 
void g(double d) 
{
    int i = convert<int>(d); // 调用 convert<int,double>(double)
    char c = convert<char>(d); // 调用 convert<char,double>(double)
    int(*ptr)(float) = convert; // 实例化 convert<int, float>(float)
}

此机制使得使用模板运算符可行,因为没有异于重写做函数调用表达式的语法为运算符指定模板实参。

#include <iostream>
int main() 
{
    std::cout << "Hello, world" << std::endl;
    // operator<< 经由 ADL 查找为 std::operator<<,
    // 然后推导出 operator<<<char, std::char_traits<char>>
    // 同时推导 std::endl 为 &std::endl<char, std::char_traits<char>>
}

模板实参推导发生后于函数模板名称查找(可能涉及参数依赖查找),先于重载决议。

6.2.4 重载与特化

为编译到函数模板的调用,编译器必须在非模板重载、模板重载和模板重载的特化间决定。

template< class T > void f(T);              // #1 :模板重载
template< class T > void f(T*);             // #2 :模板重载
void                     f(double);         // #3 :非模板重载
template<>          void f(int);            // #4 : #1 的特化
 
f('a');        // 调用 #1
f(new int(1)); // 调用 #2
f(1.0);        // 调用 #3
f(1);          // 调用 #4

注意只有非模板和初等模板重载参与重载决议。特化不是重载,且不受考虑。只有在重载决议选择最佳匹配初等函数模板后,才检验其特化以查看何为最佳匹配。

template< class T > void f(T);    // #1 :所有类型的重载
template<>          void f(int*); // #2 :为指向 int 的指针特化 #1
template< class T > void f(T*);   // #3 :所有指针类型的重载
 
f(new int(1)); // 调用 #3 ,即使通过 #1 的特化会是完美匹配

即重载的优先级要高于特化。

关于模板函数重载的更多内容,参考function_template

6.3 别名模板

类型别名是指代先前定义类型的名称(同 typedef ),别名模版是指代一族类型的名称。

template<class T>
struct Alloc { };
template<class T>
using Vec = vector<T, Alloc<T>>; // type-id 为<T, Alloc<T>>
Vec<int> v; // Vec<int> 同 vector<int, Alloc<int>>
using Vec1 = vector<int, Alloc<int>>;
Vec1 v1;	// 同 Vec<int> v

也可以用typedef进行别名模板的定义。

#include <string>
#include <ios>
#include <type_traits>
 
// 类型别名,等同于
// typedef std::ios_base::fmtflags flags;
using flags = std::ios_base::fmtflags;
// 名称 'flags' 现在指代类型:
flags fl = std::ios_base::dec;
 
// 类型别名,等同于
// typedef void (*func)(int, int);
using func = void (*) (int, int);
// 名称 'func' 现在指代指向函数的指针:
void example(int, int) {}
func f = example;
 
// 别名模板
template<class T>
using ptr = T*; 
// 名称 'ptr<T>' 现在是指向 T 指针的别名
ptr<int> x;
 
// 用于隐藏模板形参的别名模版
template<class CharT>
using mystring = std::basic_string<CharT, std::char_traits<CharT>>;
mystring<char> str;
 
// 能引入成员 typedef 名的别名模版
template<typename T>
struct Container { using value_type = T; };
// 可用于泛型编程
template<typename Container>
void g(const Container& c) { typename Container::value_type n; }
 
// 用于简化 std::enable_if 语法的类型别名
template<typename T>
using Invoke = typename T::type;
template<typename Condition>
using EnableIf = Invoke<std::enable_if<Condition::value>>;
template<typename T, typename = EnableIf<std::is_polymorphic<T>>>
int fpoly_only(T t) { return 1; }
 
struct S { virtual ~S() {} };
 
int main() 
{
    Container<int> c;
    g(c); // Container::value_type 将在此函数为 int
//  fpoly_only(c); // 错误: enable_if 禁止它
    S s;
    fpoly_only(s); // OK : enable_if 允许它
}

###6.4 变量模板

C++14起提供支持。变量模板定义一族变量或静态数据成员。

template<class T>
constexpr T pi = T(3.1415926535897932385);  // 变量模板
 
template<class T>
T circular_area(T r) // 函数模板
{
    return pi<T> * r * r; // pi<T> 是变量模板实例化
}

6.5 可变参数模板

模板参数包是接受零或更多模板实参(非类型、类型或模板)的模板形参。函数模板形参报是接受零或更多函数实参的函数形参。

模板参数包的形式如下:

type ... Args(可选)	(1)	(C++11 起)
typename|class ... Args(可选)	(2)	(C++11 起)
template < parameter-list > typename(C++17)|class ... Args(可选)	(3)	(C++11 起)

函数参数包的形式如下:

Args ... args(可选)	(4)	(C++11 起)

模板参数展开(出现于变参数模板体中):

pattern ...	(5)	(C++11 起)

其中:

  1. 带可选名称的非类型模板参数包
  2. 带可选名称的类型模板参数包
  3. 带可选名称的模板模板参数包
  4. 带可选名称的函数模板参数包
  5. 模板参数包展开:展开成零或更多 pattern 的逗号分隔列表。模式必须包含至少一个形式参数包。

至少有一个参数包的模板被称作可变参数模板。

6.5.1 可变参数模板函数

可变参数模板函数的定义如下:

template<typename ...T>
void f(T ...args)
{
cout << sizeof...(args) << endl;//打印可变参的个数
}

f();          //0
f(1, 2);      //2
f(1, 2.3, "");    //3

可以使用sizeof...获取参数包的大小。

可以使用lambda捕获参数包:

template<class ...Args>
void f(Args... args) {
    auto lm = [&, args...] { return f(args...); };
    lm();
}

展开可变模版参数函数的方法一般有两种:一种是通过递归函数来展开参数包,另外一种是通过逗号表达式来展开参数包。

#include <iostream>

using namespace std;

//递归终止函数
template<typename T>
void myprint(T end)//递归到最后一次,调用单参数函数
{
	cout << "parameter " << end << endl;
}

//展开函数
template<typename T,class ...Args>
void myprint(T head, Args... rest)
{
	cout << "parameter " << head << endl;
	myprint(rest...);
}

int main()
{
	myprint(1, 2, 3, 4);
	return 0;
}

如上是一个简单的可变模板参数函数,它打印所有的参数。这里采用了递归的方式来展开参数包。通过递归函数展开参数包,需要提供一个参展开函数和一个递归终止函数。参数包Args…在展开的过程中递归调用自己,每调用一次参数包中的参数就会少一个,直到所有的参数都展开为止,当参数包展开到最后一个参数时,则调用单参数的函数终止递归过程。

递归调用过程如下:

myprint(1,2,3,4);
myprint(2,3,4);
myprint(3,4);
myprint(4);

其实也可以把递归终止函数定义为一个空函数,例如:

void myprint()
{
}

在这种情况下,递归过程如下:

myprint(1,2,3,4);
myprint(2,3,4);
myprint(3,4);
myprint(4);
myprint();

这说明一点,如果参数包为空,也可以用参数包传参,这个时候就是调用空参数的函数重载。要注意,由于可变模板参数函数都是在编译器确定函数重载的,因此递归终止函数必须定义在展开函数前,否则会编译报错(因为无法匹配)。

这是递归展开参数包的另一个例子,很有参考价值:

template<typename T>
T sum(T t)
{
	return t;
}

template<typename T, typename ...Types>
auto sum(T first, Types... rest)
{
	return first + sum(rest...);
}
int main()
{
	cout << sum(1, 2.1, 3.2, 4) << endl; //10.3
	return 0;
}

注意到这里的sum(rest...)是之前介绍过的实参推导,通过参数包推导first的类型,这里必须要这样隐式实例化。同时函数的返回类型要写auto而不是T。

假如T为返回类型,如果第一个参数是整数,就会导致返回值只能为整数。如果是auto,那么作为int的first在和作为float的rest做运算时会返回浮点数,这样才是正确结果。如果给定参数sum<T>调用sum则会导致后面传入的浮点类型被隐式转换为整数,这些都是错误的。因此我们在展开参数包的时候一定要特别注意类型定义的细节。

此外还有一点,不像上一个例子,我们可以用空函数作为终止函数。由于需要返回值,我们必须用模板函数来作为递归终止函数,并编写相应逻辑。

接下来介绍通过逗号表达式展开参数包,这种情况通常发生在我们需要在一层逻辑中用到不止一个参数包中的参数的情况。

逗号表达式是C中的语法,可能平时使用比较少,这里先简单介绍下逗号表达式:

d = (a = b, c);

类似这样的式子,其中(a=b,c)就是一个逗号表达式。逗号表达式会按照顺序执行逗号分隔的表达式1、表达式2、表达式3…等等。最后逗号表达式会返回最后一个表达式的值。因此d的值为c。

这是一个用逗号表达式展开参数包的例子:

template<typename T>
void printarg(T t)
{
	cout << t << endl;
}

template<typename ...Args>
void myexpand(Args... args)
{
	int arr[] = { (printarg(args), 0)... };
}
int main()
{
	myexpand(1, 2, 3, 4);
    //myexpand('a', 'b', 'c', 'd'); //myexpand 里面的int数组和它的参数无关
	return 0;
}

这个例子将分别打印1,2,3,4四个数字。这种展开参数包的方式,不需要通过递归终止函数,是直接在myexpand()函数体中展开的,printarg()不是一个递归终止函数,只是一个处理参数包中的每一个参数的函数。这里使用到了C++11的列表初始化,通过列表表达式初始化变长数组arr。在初始化过程中,{(printarg(args), 0)…}将会展开成{((printarg(arg1),0), (printarg(arg2),0), (printarg(arg3),0), etc… )}。根据逗号表达式的性质,arr最后变成了一个元素值全0的数组,其大小就是参数包的大小。在构造arr的过程中,函数printarg()被执行。数组arr并没有什么实际的用途。

这是另一个例子:

template<typename F,typename ...Args>
void myexpand(const F &f, Args &&...args)
{
//使用了完美转发
	initializer_list<int>{(f(std::forward< Args>(args)), 0)...};
}

//调用
myexpand([](int i){cout << i << endl; }, 1, 2, 3); //打印 1 2 3

通过lambda表达式实现和上一个例子同样的效果,这样的好处是可以少写一个模板函数(实际上是在lambda的函数体中完成了逻辑)。myexpand的第一个参数F实际上传了一个由lambda实现的函数包装器,std::initializer_list之前在STL中已经介绍,是列表初始化的模板类。所以这里实际上用参数(f(std::forward< Args>(args)), 0)...构造了一个初始化列表。用逗号表达式调用了函数包装器F,并把参数包传入F。在列表初始化的过程中,参数包被展开。

假如是C++14,由于泛型lambda表达式的存在,还有功能更强的写法:

myexpand([](auto i) {cout << i << endl; }, 1, 2+2, "text",9.213,'z');

这样myexpand就可以接受int以外的参数了。

####6.5.2 可变参数模板类

可变参数模板类是一个带可变模板参数的模板类,比如第二章中介绍的std::tuple就是一个可变参数模板类:

std::tuple<> tp;
std::tuple<int> tp1 = std::make_tuple(1);
std::tuple<int, double> tp2 = std::make_tuple(1, 2.5);
std::tuple<int, double, string> tp3 = std::make_tuple(1, 2.5, “”);

tuple的初始化就可以接受任意个参数。

可变参数模板类的参数包的展开的方式和可变参数模板函数的展开方式不同。可变参数模板类的参数包展开需要通过模板特化和继承方式去展开,展开方式比可变参数模板函数要复杂。

先介绍如何通过模板特化展开参数包:

//向前声明
template<typename ...Args>
struct Sum;

//基本定义
template<typename First,typename ...Rest>
struct Sum<First, Rest...>
{
enum{value=Sum<First>::value+Sum<Rest...>::value};
};

//递归终止
template<typename Last>
struct Sum<Last>
{
enum{value=sizeof(Last) };
};

这个Sum类的作用是在编译期计算出参数包中参数类型的size之和。例如,通过sum< int,double,short >::value就可以获取这3个类型的size之和为14。

可以看到一个基本的可变参数模板应用类由三部分组成:

第一部分是:

template<typename ...Args>
struct Sum;

它是前向声明,声明这个sum类是一个可变参数模板类;

第二部分是类的定义:

template<typename First,typename ...Rest>
struct Sum<First, Rest...>
{
	enum{value=Sum<First>::value+Sum<Rest...>::value};
};

它定义了一个部分展开的可变模参数模板类,告诉编译器如何递归展开参数包。

第三部分是特化的递归终止类:

template<typename Last>
struct Sum<Last>
{
	enum{value=sizeof(Last) };
};

这个前向声明要求sum的模板参数至少有一个,因为可变参数模板中的模板参数可以有0个,有时候0个模板参数没有意义,就可以通过上面的声明方式来限定模板参数不能为0个。

上面的这种三段式的定义也可以改为两段式的,可以将前向声明去掉,这样定义:

template<typename First, typename... Rest>
struct Sum
{
	enum { value = Sum<First>::value + Sum<Rest...>::value };
};

template<typename Last>
struct Sum<Last>
{
	enum{ value = sizeof(Last) };
};

上面的方式只要一个基本的模板类定义和一个特化的终止函数就行了,而且限定了模板参数至少有一个。

递归终止模板类可以有多种写法,比如上例的递归终止模板类还可以这样写:

template<typename... Args> struct sum;
template<typename First, typenameLast>
struct sum<First, Last>
{ 
	enum{ value = sizeof(First) +sizeof(Last) };
};

即在展开到最后两个参数时终止。

还可以在展开到0个参数时终止:

template<>struct sum<> { enum{ value = 0 }; };

接下来介绍如何通过继承类来展开参数包:

//整型序列的定义
template<int...>
struct IndexSeq{};

//继承方式,开始展开参数包
template<int N, int... Indexes>
struct MakeIndexes : MakeIndexes<N - 1, N - 1, Indexes...> {};

// 模板特化,终止展开参数包的条件
template<int... Indexes>
struct MakeIndexes<0, Indexes...>
{
	typedefIndexSeq<Indexes...> type;
};

int main()
{
	using T = MakeIndexes<3>::type;
    cout <<typeid(T).name() << endl;
	return 0;
}

其中MakeIndexes的作用是为了生成一个可变参数模板类的整数序列。最终输出的类型是:struct IndexSeq<0,1,2>。 ​ MakeIndexes继承于自身的一个特化的模板类,这个特化的模板类同时也在展开参数包,这个展开过程是通过继承发起的,直到遇到特化的终止条件展开过程才结束。

MakeIndexes<1,2,3>::type的展开过程是这样的:

MakeIndexes<3> : MakeIndexes<2, 2>{}
MakeIndexes<2, 2> : MakeIndexes<1, 1, 2>{}
MakeIndexes<1, 1, 2> : MakeIndexes<0, 0, 1, 2>
{
	typedef IndexSeq<0, 1, 2> type;
}

通过不断的继承递归调用,最终得到整型序列IndexSeq<0, 1, 2>

如果不希望通过继承方式去生成整形序列,则可以通过下面的方式生成:

template<int N, int... Indexes>
struct MakeIndexes3
{
	using type = typename MakeIndexes3<N - 1, N - 1, Indexes...>::type;
};

template<int... Indexes>
struct MakeIndexes3<0, Indexes...>
{
	typedef IndexSeq<Indexes...> type;
};

6.5.3 应用

我们通常可以用可变参数模板来消除大量重复代码及实现一些高级功能。

C++11之前如果要写一个泛化的工厂函数,这个工厂函数能接受任意类型的入参,并且参数个数要能满足大部分的应用需求的话,我们不得不定义很多重复的模版定义,比如下面的代码:

template<typename T>
T* Instance()
{
    return new T();
}

template<typename T, typename T0>
T* Instance(T0 arg0)
{
    return new T(arg0);
}

template<typename T, typename T0, typename T1>
T* Instance(T0 arg0, T1 arg1)
{
    return new T(arg0, arg1);
}

template<typename T, typename T0, typename T1, typename T2>
T* Instance(T0 arg0, T1 arg1, T2 arg2)
{
    return new T(arg0, arg1, arg2);
}

template<typename T, typename T0, typename T1, typename T2, typename T3>
T* Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3)
{
    return new T(arg0, arg1, arg2, arg3);
}

template<typename T, typename T0, typename T1, typename T2, typename T3, typename T4>
T* Instance(T0 arg0, T1 arg1, T2 arg2, T3 arg3, T4 arg4)
{
    return new T(arg0, arg1, arg2, arg3, arg4);
}
struct A
{
    A(int){}
};

struct B
{
    B(int,double){}
};
A* pa = Instance<A>(1);
B* pb = Instance<B>(1,2);

可以看到这个泛型工厂函数存在大量的重复的模板定义,并且限定了模板参数。用可变模板参数可以消除重复,同时去掉参数个数的限制,代码很简洁, 通过可变参数模版优化后的工厂函数如下:

template<typename…  Args>
T* Instance(Args&&… args)
{
    return new T(std::forward<Args>(args)…);
}
A* pa = Instance<A>(1);
B* pb = Instance<B>(1,2);

###6.6 模板特化

有的时候我们必须针对某个模板的实参定制特化的模板代码,这个时候我们就需要用到模板特化。其语法如下:

template <> declaration		

任何下列者可以完全特化:

  1. 函数模板
  2. 类模板
  3. (C++14 起)变量模板
  4. 类模板的成员函数
  5. 类模板的静态数据成员
  6. 类模板的成员类
  7. 类模板的成员枚举
  8. 类或类模板的成员类模板
  9. 类或类模板的成员函数模板

例如:

#include <iostream>
template<typename T>   // 初等模板
struct is_void : std::false_type
{
};
template<>  // 对 T = void 显式特化
struct is_void<void> : std::true_type
{
};
int main()
{
    // 对于任何异于 void 的类型 T ,类导出自 false_type
    std::cout << is_void<char>::value << '\n'; 
    // 但当 T 为 void 时类导出自 true_type
    std::cout << is_void<void>::value << '\n';
}

特化函数模板时,可忽略其实参,若模板实参推导能从函数参数提供它们:

template<class T> class Array { /*...*/ };
template<class T> void sort(Array<T>& v); // 初等模板
template<> void sort(Array<int>&); // 对 T = int 的特化
// 不需要写
// template<> void sort<int>(Array<int>&);

不能在函数模板、成员函数模板,及在隐式实例化类时的类模板的成员函数的显式特化中指定默认函数参数。显式特化不能是友元声明。

在类体外定义显式特化的类模板的成员时,不使用 template <> 语法,除非它是作为类模板特化的显式特化的成员类模板的成员,因为其他情况下,语法会要求这种定义以嵌套模板所要求的 template 开始:

template< typename T>
struct A {
    struct B {};  // 成员类 
    template<class U> struct C { }; // 成员类模板
};
 
template<> // 特化
struct A<int> {
    void f(int); // 特化的成员函数
};
// template<> 不用于特化的成员
void A<int>::f(int) { /* ... */ }
 
template<> // 成员类的特化
struct A<char>::B {
    void f();
};
// template<> 亦不用于特化的成员类的成员
void A<char>::B::f() { /* ... */ }
 
template<> // 成员类模板的的定义
template<class U> struct A<char>::C {
    void f();
};
 
// template<> 在作为类模板定义显式特化的成员类模板时使用
template<>
template<class U> void A<char>::C<U>::f() { /* ... */ }

模板的静态数据成员的显式特化是定义,若声明包含初始化器;否则,它是声明。这些定义对于默认初始化必须用花括号:

template<> X Q<int>::x; // 静态成员的声明
template<> X Q<int>::x (); // 错误:函数声明
template<> X Q<int>::x {}; // 静态成员的默认初始化定义

类模板的成员或成员模板可对于类模板的隐式实例化显式特化,即使成员或成员模板定义于类模板定义中。

template<typename T>
struct A {
    void f(T); // 成员,声明于初等模板
    void h(T) {} // 成员,定义于初等模板
    template<class X1> void g1(T, X1); // 成员模板
    template<class X2> void g2(T, X2); // 成员模板
};
 
// 成员的特化
template<> void A<int>::f(int);
// 成员特化 OK ,即使定义于类中
template<> void A<int>::h(int) {}
 
// 类外成员模板定义
template<class T>
template<class X1> void A<T>::g1(T, X1) { }
 
// 成员模板特化
template<>
template<class X1> void A<int>::g1(int, X1);
 
// 成员模板特化
template<>
template<> void A<int>::g2<char>(int, char); // 对于 X2 = char
// 同上,用模板实参推导 (X1 = char)
template<> 
template<> void A<int>::g1(int, char);

成员或成员模板可嵌套于多个外围类模板中。在这种成员的显式特化中,对每个显式特化的外围类模板都有一个 template<> 。

template<class T1> class A {
    template<class T2> class B {
        void mf();
    };
};
template<> template<> class A<int>::B<double>;
template<> template<> void A<char>::B<char>::mf();

在这种嵌套声明中,某些层次可保留不特化(除了若其外围类不特化,则不能特化类成员模板)。对于每个这种层次,声明需要 template ,因为这种特化自身是模板:

template <class T1> class A {
    template<class T2> class B {
        template<class T3> void mf1(T3); // 成员模板
        void mf2(); // 非模板成员
     };
};
 
// 特化
template<> // 对于特化的 A
template<class X> // 对于不特化的 B
class A<int>::B {
    template <class T> void mf1(T);
};
 
// 特化
template<> // 对于特化的 A
template<> // 对于特化的 B
template<class T> // 对于不特化的 mf1
void A<int>::B<double>::mf1(T t) { }
 
// 错误: B<double> 被特化而且是成员模板,故其外围的 A 也必须特化
template<class Y>
template<> void A<Y>::B<double>::mf2() { }

7.Strongly-typed enums 强类型枚举

在C++11以前的枚举类型中,枚举类型的名字都在其父作用域空间可见的。例如:

enum Type { General, Light, Medium, Heavy };
enum Category{ General, Pistol, MachineGun, Cannon };

由于Category中的General和Type中的General都是全局的名字,因此编译器会报错。另外一个缺陷是传统枚举值总是被隐式转换为整形,用户无法自定义类型。我们通常使用的变量个数都不超过255个,也就是说用一个字节存储就足够了。但是,枚举变量却是按整形来存储的。我们多么希望可以指定存储类型,对于小于255的enum变量,要是可以指定用char来存储就好了。C++11中的强类型枚举解决了这些问题。

声明强类型枚举很简单,只需要在原有的enum后加上关键字class即可。

enum class Type { General, Light, Medium, Heavy };

这样,就声明了一个强类型枚举。强类型枚举有以下几点优势:

  • 强作用域,强类型枚举成员的名称不会被输出到其父作用域空间。

  • 转换限制,强类型枚举成员的值不可以与整型隐式地相互转换。

  • 可以指定底层存储类型,强类型枚举默认的底层类型为int,但也可以显式地指定底层存储类型,具体的做法就是在枚举名称后面加上冒号和类型,该类型可以是除wchar_t之外的任何整形类型。比如:

    enum class Type : char { General, Light, Medium, Heavy };

8.面向对象程序设计

8.1 类的列表初始化

一个类(class struct union)是否可以使用列表初始化来完成初始化工作,取决于类是否是一个聚合体(aggregate),首先看下C++中关于类是否是一个聚合体的定义:

  1. 无用户自定义构造函数。
  2. 无私有或者受保护的非静态数据成员
  3. 无基类
  4. 无虚函数
  5. 无{}和=直接初始化的非静态数据成员

8.2 构造函数初始化列表

与其他函数不同,构造函数除了有名字,参数列表和函数体之外,还可以有初始化列表,初始化列表以冒号开头,后跟一系列以逗号分隔的初始化字段。从概念上来讲,构造函数的执行可以分成两个阶段,初始化阶段和计算阶段,初始化阶段先于计算阶段。

class foo
{
public:
	foo(string s, int i):name(s), id(i){} ; // 初始化列表
private:
	string name ;int id ;
};

必须在类初始化列表中初始化的几种情况:

  1. 类成员为const类型
  2. 类成员为引用类型
  3. 类成员为没有默认构造函数的类类型
  4. 如果类存在继承关系,派生类必须在其初始化列表中调用基类的构造函数

8.3 虚函数的override和final指示符

override 可以帮助程序员的意图更加的清晰的同时让编译器可以为我们发现一些错误,其只能用于覆盖基类的虚函数;final 使得任何尝试覆盖该函数的操作都将引发错误,并不特指虚函数。这些修饰符均出现在形参列表(包括任何const或者引用限定符)以及尾置返回类型之后。

struct A
{
    virtual void foo();
    void bar();
};
 
struct B : A
{
    void foo() const override; // 错误: B::foo 不覆写 A::foo
                               // (签名不匹配)
    void foo() override; // OK : B::foo 覆写 A::foo
    void bar() override; // 错误: A::bar 非虚
};

struct Base
{
    virtual void foo();
};
 
struct A : Base
{
    void foo() final; // A::foo 被覆写且是最终覆写
    void bar() final; // 错误:非虚函数不能被覆写或是 final
};
 
struct B final : A // struct B 为 final
{
    void foo() override; // 错误: foo 不能被覆写,因为它在 A 中是 final
};
 
struct C : B // 错误: B 为 final
{
};

8.4 继承构造函数

在C++继承中,我们可能会遇到下面这个例子:

class Base
{
public:
	Base(int va)
		:m_value(va)
	{
 
	}
	Base(char c)
		:m_c(c)
	{
 
	}
private:
	int m_value;
	char m_c;
};
class Derived :public Base
{
private:
public:
	//假设派生类只是添加了一个普通的函数
	void display()
	{
 
	}
	//那么如果我们在构造B的时候想要拥有A这样的构造方法的话,就必须一个一个的透传各个接口,那么这是很麻烦的
	Derived(int va)
		:Base(va)
	{
 
	}
	Derived(char c)
		:Base(c)
	{
 
	}
};

上面过程是很麻烦的,但是呢C++11中推出了继承构造函数,使用using来声明继承基类的构造函数,我们可以这样写:

class Base1
{
public:
	Base1(int va)
		:m_value(va)
	{
 
	}
	Base1(char c)
		:m_c(c)
	{
 
	}
private:
	int m_value;
	char m_c;
};
class Derived1 :public Base1
{
private:
	int m_d{0};
public:
	//假设派生类只是添加了一个普通的函数
	void display()
	{
 
	}
	//使用继承构造函数
	using Base1::Base1;
};

而且,更神奇的是,C++11标准继承构造函数被设计为跟派生类中的各个类默认函数(默认构造,析构,拷贝构造等)一样是隐式声明的。那么这就意味着如果一个继承构造函数不被相关代码使用,编译器就不会产生真正的函数代码,这样比透传更加节省了空间。

要注意以下几点:

  1. 继承构造函数只会初始化基类的成员变量,对于派生类的成员变量就无能为力
  2. 基类的构造函数可能会有默认值,但是对于继承构造函数来讲,参数的默认值是不会被继承的。
  3. 私有构造是不会被继承的
  4. 在多继承的情况下,可能出现冲突的情况

8.5 委派构造函数

c++11的委派构造函数是在构造函数的初始化列表位置进行构造的,委派的:

class Info
{
private:
	void Init()
	{
		/*一些初始化操作*/
	}
	int type = 3;
	char c = 'D';
public:
	Info()
	{
		Init();
	}
	Info(int i)
		:type(i)
	{
		Init();
	}
	Info(char cc)
		:c(cc)
	{
		Init();
	}
};

这样我们三个构造函数,都调用了Init初始化,这样很麻烦,我们可以利用委托构造函数改写:

class Info1
{
private:
	void Init()
	{
		/*一些初始化操作*/
	}
	int type = 3;
	char c = 'D';
public:
	Info1()
	{
		
	}
	Info1(int i)
		:Info1()
	{
		type = i;
	}
	Info1(char cc)
		:Info1()
	{
		c = cc;
	}
};

这样的版本就比上面简单多了。上面的Init()函数被称为目标构造函数,其它两个构造函数被称为委派构造函数。要注意,不能同时使用委派构造函数和初始化列表。

9.其他

到此,我已经列出了我在工作中曾经了解并使用过的C++11新特性,有关更多C++11及之后的高级特性,请参考cpp_reference