使用 C++17 fold 表达式来大幅提升的QString的拼接效率

一、前言

最近学习C++17,发现一个有趣的表达式-fold expression(折叠表达式)。为什么说它是有趣的?我们先说一下另外一个C++的特性-变参模板(variadic template),这是C++11新增的的特性,作用就是它可以接受任意个模版参数,参数包不能直接展开,需要通过一些特殊的方法,比如函数参数包的展开可以使用递归方式或者逗号表达式,在使用的时候有点难度。而这次C++17中新推出的fold,就大大简化了变参模板的使用方式,我们可以通过fold表达式简化对参数包的展开。对于参数表达式,我们可以将其作用到QString字符串上,可以大幅提升其拼接效率。

二、QString 字符串的拼接

作为使用C++开发的老师,无论我们使用标准的STL还是Qt,我们早就已经习惯使用 运算符 “+” 进行字符串的拼接了。例如,我们要拼接一个字符串 “I‘m a teacher in xueersi.’”:

QString name{"I'm a teacher"};
QString space{" "};
QString company{"in xueersi"};
QString period{"."};
QString result = name + space + company + period;

虽然上面的拼接是没有问题的,能得到正确的结果。但是这样做的效率很低,原因是拼接过程中不必要地产生临时的中间结果。也就是说,在前面的示例中,我们有一个临时字符串来保存 name + space 的结果,然后该字符串与 company 拼接起来,这会产生另一个临时字符串。第二个临时字符串再与 period 拼接,并产生最终结果字符串,最后销毁前述所有临时字符串。
        这意味着我们有几乎和运算符"+"一样多不必要的内存分配和释放。而且,还要多次拷贝相同的内容。例如,name字符串的内容首先被复制到第一个临时对象中,然后从第一个临时对象复制到第二个临时对象中,然后从第二个临时对象复制到最终结果中。

使用 C++17 fold 表达式来大幅提升的QString的拼接效率_第1张图片

可以用一个效率高得多的方式,即创建一个字符串实例,预先分配最终所需的内存,然后反复调用QString::append函数来逐个追加所有要拼接的字符串:

QString result;
result.reserve(name.length() + space.length() + company.length() + period.length();
result.append(name);
result.append(space);
result.append(company);
result.append(period);

使用 C++17 fold 表达式来大幅提升的QString的拼接效率_第2张图片

或者,我们可以使用QString::resize替换QString::reserve,然后使用std::copy(或std::memcpy)把数据复制到这里面。这可能会稍微提高性能,因为QString::append需要检查字符串的容量是否足够大以包含结果字符串。std::copyalgorithm没有这个无用的额外检查,这可能会给它一点优势。

        这两种方法都比使用运算符+效率高得多,但是如果每次我们想要拼接几个字符串时都必须这样写代码会很烦人。

三、std::accumulate算法

在我们继续讨论Qt如何解决这个问题之前,还有一个可行的方法:一个C++ 17中的新特性,它可以解决这个问题,这里就要介绍一下这个标准库中最重要和最强大的算法之一:std::accumulate。

假设我们有一个字符串序列(例如QVector),我们希望将它们拼接起来,而不是将它们放在单独的变量中。
使用std::accumulate的字符串拼接代码如下:

QVector result{ . . . };
std::accumulate(result.cbegin(), result.cend(), QString{});

该算法实现了您期望的功能——它从一个空的QString开始,并将向量中的每个字符串相加,从而创建一个拼接字符串。
然而由于在默认情况下std::accumulate在内部使用运算符+,因此这与我们最初使用运算符+进行拼接的示例一样效率低下。
为了像前一节一样优化这个实现,我们可以只使用std::accumulate来计算结果字符串的大小,而不使用它进行整体拼接:

QVector str{ . . . };
QString result;
result.resize(std::accumulate(str.cbegin(), str.cend(), 0, [] (int acc, const QString& s) {
              return s.length();}));

        这次,std::accumulate从初始值0开始,对于字符串向量中的每个字符串,它将该初始值的长度相加,最后返回向量中所有字符串的长度总和。
        这就是std::accumulate对大多数人的意义——某种求和算法。但这只是一种相当粗浅的认知。
在第一个例子中,我们对例子中的所有字符串进行了求和(即拼接字符串)。但第二个例子有点不同。我们实际上不是求向量元素的和。该向量包含QString,而我们求和的是int。
        这就是std::accumulate功能强大的原因:事实上,我们可以向它传递一个自定义操作。该操作函数输入先前的累积值和源集合的一个元素,并生成新的累积值。std::accumulate第一次调用操作函数时,会把初始值作为累积值传递给它,同时把源集合的第一个元素传递给它。该操作函数将计算出新的累积值并将其与源集合的第二个元素一起传递给操作函数的下一个调用。这将重复,直到处理完整个源集合,算法将返回最终操作函数调用的结果。
如前一个代码片段所示,累积值甚至不需要与向量中的元素具有相同的类型。当累积值是整数时,源向量是一个字符串向量。

知道了这些,我们就可以来更进一步实现拼接了:

前面提到的std::copy算法接收一个被复制的序列(是一对输入iterator)和复制目标(是一个输出iterator),它指向拷贝的目标集合和起始点。算法返回一个iterator,指向复制目标集合中最后一个被复制项之后的元素。
        这就说明,如果我们使用std::copy将一个源字符串的数据复制到目标字符串中,我们应该让iterator指向将要存放字符串数据的位置。
        于是,我们就有了一个这样的函数:它接受一个字符串(作为一对iterator)和一个输出迭代器,并为我们返回一个新的输出迭代器。这就可以用于std::accumulate的操作函数,来实现高效的字符串拼接了:

QVector str{ . . . };
QString result;
result.resize( . . . );

std::accumulate(str.cbegin(), str.cend(), result.begin(), [] (const auto& dest, const QString& s) {
                    return std::copy(s.cbegin(), s.cend(), dest);
                });

对std::copy的第一次调用将把第一个字符串复制到result.begin()指向的目标。它将返回result字符串中最后一个复制字符之后的iterator,然后vector中的第二个字符串将从这个位置开始复制。之后再复制第三个字符串,依此类推。

使用 C++17 fold 表达式来大幅提升的QString的拼接效率_第3张图片

最终,我们得到一个拼接后的字符串。

四、递归表达式模板

        现在我们可以继续讨论如何用Qt的运算符+实现高效的字符串拼接了:

QString result = name + space + company + period;

经过上面的介绍,我们已经知道,字符串拼接的性能问题源于C++会分步解析上述表达式,多次调用运算符+,并且每次调用都会产生新的QString实例为临时变量。
        虽然我们不能改变C++的解析过程,但是我们可以使用一种称为表达式模板(expression templates)的方式来延迟结果字符串的实际计算,直到整个表达式解析全部完成。这需要将运算符+的返回类型从原来的QString改为一种自定义类型,该类型只存储要被拼接的字符串,而不实际执行拼接。这样对于字符串拼接就产生了一个更复杂的版本:

template 
class QStringBuilder {
    const Left& _left;
    const Right& _right;
};

        拼接多个字符串时,您将得到一个更复杂的类型,其中多个QStringBuilder相互嵌套。像这样:

QStringBuilder>>

这种类型只是用了一种复杂的方式来表达“我有四个字符串需要拼接”。当我们请求将QStringBuilder转换为QString时,它将首先计算所有包含的字符串的总大小,然后将分配该大小的QStringinstance,最后,它将字符串逐个复制到结果字符串中。

        从本质上讲,它的功能与我们之前做的完全相同,但它是自动完成的,完全不需要我们费力。

五、可变参模板(Variadic templates)

当前QStringBuilder实现的问题是:它通过嵌套实现能容纳任意数量字符串的容器。每个QStringBuilder实例可以恰好包含两个项,可以是字符串或是其他QStringBuilder实例。

        这意味着QStringBuilder的所有实例都是一种二叉树,其中QString是叶节点。每当需要对包含的字符串执行某些操作时,QStringBuilder需要处理其左子树,然后递归地处理右子树。

        除了使用二叉树,我们还可以使用可变参模板。可变参模板允许我们创建具有任意数量的模板参数的类和函数。
这意味着,通过使用元组std::tuple我们可以创建一个QStringBuilder模板类,包含任意多个字符串:

template 
class QStringBuilder {
    std::tuple _strings;
};

 当获得一个新的字符串且要添加到QStringBuilder时,我们只需使用std::tuple_cat将两个元组拼接起来:

template 
class QStringBuilder {
    std::tuple _strings;

    template 
    auto operator%(String&& newString) &&
    {
        return QStringBuilder(
            std::tuple_cat(_strings, std::make_tuple(newString)));
    }
};

六、折叠表达式

接下来我们还要介绍拼接QString所运用的最重要的C++17的新特性-fold expression,也就是我们标题中的实现高效字符串拼接的方法。

我们这里就不说fold的定义了,我们使用最直白的方式来介绍fold的作用。它和std::accumulate的行为非常类似。唯一的区别是std::accumulate算法是处理数据的运行时序列(向量、数组、列表等),而折叠表达式处理的是编译时序列,即可变参模板的参数包。

我们可以遵循与std::accumulate相同的步骤来优化之前的拼接实现。首先,我们需要计算所有字符串长度的和。这对于折叠表达式来说非常简单:

template 
auto concatenate(Strings... strings)
{
    const auto totalSize = (0 + ... + strings.length());
    . . .
}

当折叠表达式展开参数包时,它将得到以下表达式:

0 + string1.length() + string2.length() + string3.length()

于是,我们得到了结果字符串的大小。现在可以继续分配一个能够容纳结果的字符串,并将源字符串逐个追加到该字符串中。
        如前所述,折叠表达式可以与C++的二元运算符一起使用。如果想为参数包中的每个元素执行一个函数,我们可以使用C和C++中最神奇的运算符之一:逗号运算符。

template 
auto concatenate(Strings... strings)
{
    const auto totalSize = (0 + ... + strings.length());
    QString result;
    result.reserve(totalSize);

    (result.append(strings), ...);

    return result;
}

        以上会为参数包中的每个字符串调用append函数,最后返回拼接完成的字符串。

七、使用折叠表达式自定义运算符

之前对std::accumulate采用的第二种方式有些复杂:我们必须提供一个自定义的累加操作函数。而累计值是目标集合中的迭代器,它指向下一个字符串的复制位置。

        如果我们想使用折叠表达式自定义操作函数,那么就需要创建一个二元运算符。就像我们传递给std::accumulate的lambda表达式一样,该运算符需要获得一个输出迭代器和一个字符串,它需要调用std::copy将字符串内容复制到该迭代器,同时返回一个新的迭代器,该迭代器指向最后复制的字符之后的元素。为了更方便操作,于是,我们重载了操作符<<:

template 
auto operator<< (Dest dest, const String& string)
{
    return std::copy(string.cbegin(), string.cend(), dest);
}

有了这个操作符,使用折叠表达式将所有字符串复制到目标缓冲区就变得非常简单。初始值是目标缓冲区的初始迭代器,我们将参数包中的每个字符串传递给操作符<<:

template 
auto concatenate(Strings... strings)
{
    const auto totalSize = (0 + ... + strings.length());
    QString result;
    result.resize(totalSize);

    (result.begin() << ... << strings);

    return result;
}

        这样,我们在使用折叠表达式,就会更加的方便。

八、折叠表达式和元组

现在,我们知道如何有效地拼接字符串集合,无论是使用向量还是可变模板参数包。
       问题是我们的QStringBuilder两者都没用。它将字符串存储在std::tuple中,既不是可迭代集合,也不是参数包。
       为了使用折叠表达式,我们需要参数包。我们可以创建一个包含从0到n-1的索引列表的参数包来代替包含字符串的参数包,稍后我们可以使用std::get来访问元组内部的值。、
        通过std::index_sequence很容易创建这个参数包,该序列表示一个编译时的整数列表。我们可以创建一个helper函数,它以std::index_sequence 作为参数,然后在折叠表达式中使用std::get(_strings)逐个访问元组中的字符串。

template 
class QStringBuilder {
    using Tuple = std::tuple;
    Tuple _strings;

    template 
    auto concatenateHelper(std::index_sequence) const
{
        const auto totalSize = (std::get(_strings).size() + ... + 0);

        QString result;
        result.resize(totalSize);

        (result.begin() << ... << std::get(_strings));

        return result;
    }
};

我们只需要创建一个包装函数来为元组创建索引序列,然后调用concatenateHelper函数:

template 
class QStringBuilder {
    . . .

    auto concatenate() const
{
        return concatenateHelper(
            std::index_sequence_for{});
    }
};

九、总结

本文仅仅是使用C++17的fold expression表达式来实现我们日常使用Qt开发中的字符串高效的拼接。对于其中的QStringBuilder 也只是做了浅显的说明。其实QStringBuilder是一个非常复杂的实现,以后学习的地方还有很多。

你可能感兴趣的:(C++,QT,C++17,fold,QString,拼接,高效)