C++-现代编程(三)

88 阅读1小时+

C++ 现代编程(三)

原文:annas-archive.org/md5/F02528C543403FA60BC7527E0C58459D

译者:飞龙

协议:CC BY-NC-SA 4.0

使用标准库容器

标准库提供了几种类型的容器;每种容器都是通过模板类提供的,因此容器的行为可以用于任何类型的项目。有顺序容器的类,其中容器中项目的排序取决于项目插入容器的顺序。还有排序和未排序的关联容器,它们将值与键关联起来,随后使用键访问值。

尽管它们本身不是容器,在本章中我们还将介绍两个相关的类:pair将两个值链接在一个对象中,tuple可以在一个对象中保存一个或多个值。

使用对和元组

在许多情况下,您将希望将两个项目关联在一起;例如,关联容器允许您创建一种数组类型,其中除数字以外的项目用作索引。<utility>头文件包含一个名为pair的模板类,它有两个名为firstsecond的数据成员。

    template <typename T1, typename T2> 
    struct pair 
    { 
        T1 first; 
        T2 second; 
        // other members 
    };

由于该类是模板化的,这意味着您可以关联任何项目,包括指针或引用。访问成员很简单,因为它们是公共的。您还可以使用get模板化函数,因此对于pair对象p,您可以调用get<0>(p)而不是p.first。该类还具有复制构造函数,因此您可以从另一个对象创建对象,并且移动构造函数。还有一个名为make_pair的函数,它将从参数中推断出成员的类型:

    auto name_age = make_pair("Richard", 52);

要小心,因为编译器将使用它认为最合适的类型;在这种情况下,创建的pair对象将是pair<const char*,int>,但是如果要使first项目成为string,则使用构造函数更简单。您可以比较pair对象;比较是在第一个成员上执行的,只有在它们相等时才会比较第二个:

    pair <int, int> a(1, 1); 
    pair <int, int> a(1, 2); 
    cout << boolalpha; 
    cout << a << " < " << b << " " << (a < b) << endl;

参数可以是引用:

    int i1 = 0, i2 = 0; 
    pair<int&, int&> p(i1, i2); 
    ++p.first; // changes i1

make_pair函数将从参数中推断出类型。编译器无法区分变量和对变量的引用。在 C++11 中,您可以使用ref函数(在<functional>中)来指定pair将用于引用:

    auto p2 = make_pair(ref(i1), ref(i2)); 
    ++p2.first; // changes i1

如果要从函数返回两个值,可以通过引用传递的参数来实现,但是代码不太可读,因为您期望通过函数的返回而不是通过其参数来获得返回值。pair类允许您在一个对象中返回两个值。一个例子是<algorithm>中的minmax函数。这返回一个包含参数的pair对象,按最小值的顺序排列,并且有一个重载,如果不应使用默认操作符<,则可以提供谓词对象。以下将打印{10,20}

    auto p = minmax(20,10);  
    cout << "{" << p.first << "," << p.second << "}" << endl;

pair类关联两个项目。标准库提供了tuple类,具有类似的功能,但由于模板是可变的,这意味着您可以具有任意数量的任何类型的参数。但是,数据成员不像pair中那样命名,而是通过模板化的get函数访问它们:

    tuple<int, int, int> t3 { 1,2,3 }; 
    cout << "{" 
        << get<0>(t3) << "," << get<1>(t3) << "," << get<2>(t3)  
        << "}" << endl; // {1,2,3}

第一行创建了一个包含三个int项目的tuple,并使用初始化列表进行初始化(您可以使用构造函数语法)。然后通过使用get函数的版本访问对象中的每个数据成员将tuple打印到控制台,其中模板参数指示项目的索引。请注意,索引是模板参数,因此您无法使用变量在运行时提供它。如果这是您想要做的事情,那么这清楚地表明您需要使用诸如vector之类的容器。

get函数返回一个引用,因此可以用来更改项目的值。对于tuple t3,此代码将第一个项目更改为42,第二个项目更改为99

    int& tmp = get<0>(t3); 
    tmp = 42; 
    get<1>(t3) = 99;

您还可以使用tie函数一次提取所有项目。

    int i1, i2, i3; 
    tie(i1, i2, i3) = t3; 
    cout << i1 << "," << i2 << "," << i3 << endl;

tie函数返回一个tuple,其中每个参数都是引用,并初始化为您传递的参数的变量。如果您这样写,前面的代码更容易理解:

    tuple<int&, int&, int&> tr3 = tie(i1, i2, i3); 
    tr3 = t3;

tuple对象可以从pair对象创建,因此您也可以使用tie函数从pair对象中提取值。

有一个名为make_tuple的辅助函数,它将推断参数的类型。与make_pair函数一样,您必须注意推断,因此浮点数将被推断为double,整数将是int。如果要求参数是特定变量的引用,可以使用ref函数或cref函数用于const引用。

只要项目数量相等且类型相等,就可以比较tuple对象。如果tuple对象的项目数量不同,或者一个tuple对象的项目类型无法转换为另一个tuple对象的类型,编译器将拒绝编译比较。

容器

标准库容器允许您将相同类型的零个或多个项目组合在一起,并通过迭代器顺序访问它们。每个这样的对象都有一个begin方法,返回指向第一个项目的迭代器对象,以及一个end函数,返回指向容器中最后一个项目后面的项目的迭代器对象。迭代器对象支持类似指针的算术运算,因此end() - begin()将给出容器中的项目数。所有容器类型都将实现empty方法,以指示容器中是否没有项目,并且(除了forward_listsize方法是容器中项目的数量。您可能会尝试通过容器进行迭代,就像它是一个数组一样:

    vector<int> primes{1, 3, 5, 7, 11, 13}; 
    for (size_t idx = 0; idx < primes.size(); ++idx)  
    { 
        cout << primes[idx] << " "; 
    } 
    cout << endl;

问题在于,并非所有容器都允许随机访问,如果您决定使用另一个容器更有效,就必须更改容器的访问方式。如果要使用模板编写通用代码,这段代码也不起作用。最好使用迭代器编写前面的代码:

    template<typename container> void print(container& items) 
    { 
        for (container::iterator it = items.begin();  
        it != items.end(); ++it) 
        { 
            cout << *it << " "; 
        } 
        cout << endl; 
    }

所有容器都有一个名为iteratortypedef成员,该成员给出从begin方法返回的迭代器的类型。迭代器对象的行为类似于指针,因此可以使用解引用运算符获取迭代器引用的项目,并使用增量运算符移动到下一个项目。

除了vector之外的所有容器都保证迭代器在删除其他元素时仍然有效。如果插入项目,则只有listsforward_lists和相关容器保证迭代器保持有效。稍后将更深入地介绍迭代器。

所有容器都必须具有一个名为swap的异常安全(无异常)方法,并且(有两个例外)它们必须具有事务语义;也就是说,操作必须成功或失败。如果操作失败,则容器的状态与调用操作之前相同。对于每个容器,在进行多元素插入时,此规则在某种程度上放松。例如,如果使用迭代器范围一次插入多个项目,并且范围中的某个项目插入失败,则该方法将无法撤消先前的插入。

需要指出的是,对象被复制到容器中,因此放入容器中的对象的类型必须具有复制和复制赋值运算符。还要注意,如果将派生类对象放入需要基类对象的容器中,那么复制将切割对象,这意味着与派生类有关的任何内容都将被删除(数据成员和虚方法指针)。

序列容器

序列容器存储一系列项目以及它们存储的顺序,当您使用迭代器访问它们时,项目将按照它们放入容器的顺序检索。创建容器后,您可以使用库函数更改排序顺序。

列表

正如名称所示,list对象是通过双向链表实现的,其中每个项目都有一个链接到下一个项目和上一个项目的链接。这意味着快速插入项目(就像第二章中的示例所示的那样,使用单链表),但是由于在链表中,项目只能访问其前面和后面的项目,因此没有[]索引运算符的随机访问。

该类允许您通过构造函数提供值,或者您可以使用成员方法。例如,assign方法允许您使用初始化列表一次填充容器,或者使用迭代器将范围填充到另一个容器。您还可以使用push_backpush_front方法插入单个项目:

    list<int> primes{ 3,5,7 }; 
    primes.push_back(11); 
    primes.push_back(13); 
    primes.push_front(2); 
    primes.push_front(1);

第一行创建一个包含357list对象,然后将1113推送到末尾(按顺序),使得list包含{3,5,7,11,13}。然后代码将数字21推送到前面,使得最终的list{1,2,3,5,7,11,13}。尽管名称如此,pop_frontpop_back方法只是删除列表的前面或后面的项目,但不会返回项目。如果要获取已删除的项目,必须首先通过frontback方法访问项目:

    int last = primes.back(); // get the last item 
    primes.pop_back();        // remove it

clear方法将删除list中的所有项目,erase方法将删除项目。有两个版本:一个带有标识单个项目的迭代器,另一个带有指示范围的两个迭代器。通过提供范围的第一个项目和范围之后的项目来指示范围。

    auto start = primes.begin(); // 1 
    start++;                     // 2 
    auto last = start;           // 2 
    last++;                      // 3 
    last++;                      // 5 
    primes.erase(start, last);   // remove 2 and 3

这是一个与迭代器和标准库容器相关的一般原则;一个范围由迭代器指示,第一个项目和最后一个项目之后的项目。remove方法将删除所有具有指定值的项目:

    list<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    planck.remove(6);            // {2,0,7,0,0,4,0}

还有一个remove_if方法,它接受一个谓词,只有在谓词返回true时才会删除一个项目。类似地,您可以使用迭代器将项目插入列表,并且项目将在指定项目之前插入:

    list<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    auto it = planck.begin(); 
    ++it; 
    ++it; 
    planck.insert(it, -1); // {6,6,-1,2,6,0,7,0,0,4,0}

您还可以指示应在该位置插入项目多次(如果是这样,还可以提供多少个副本),并且可以在一个位置提供多个项目。当然,如果您传递的迭代器是通过调用begin方法获得的,则项目将插入到list的开头。通过调用push_front方法也可以实现相同的效果。同样,如果迭代器是通过调用end方法获得的,则项目将插入到list的末尾,这与调用push_back相同。

调用insert方法时,您提供一个要复制或移动到list中的对象(通过右值语义)。该类还提供了几个emplace方法(emplaceemplace_frontemplace_back),它们将根据您提供的数据构造一个新对象,并将该对象插入list中。例如,如果您有一个可以从两个double值创建的point类,您可以insert一个构造的point对象或通过提供两个doubleemplace一个point对象:

    struct point 
    { 
        double x = 0, y = 0; 
        point(double _x, double _y) : x(_x), y(_y) {} 
    }; 

    list<point> points; 
    point p(1.0, 1.0); 
    points.push_back(p); 
    points.emplace_back(2.0, 2.0);

创建list后,可以使用成员函数对其进行操作。swap方法接受一个合适的list对象作为参数,将参数中的项目移动到当前对象中,并将当前list中的项目移动到参数中。由于list对象是使用链表实现的,因此此操作很快。

    list<int> num1 { 2,7,1,8,2,8 }; // digits of Euler's number 
    list<int> num2 { 3,1,4,5,6,8 }; // digits of pi 
    num1.swap(num2);

在此之后,代码num1将包含{3,1,4,5,6,8}num2将包含{2,7,1,8,2,8},如下所示:

list将按照插入容器的顺序保存项目;但是,您可以通过调用sort方法对它们进行排序,默认情况下,将使用<运算符对list容器中的项目进行升序排序。您还可以传递一个函数对象进行比较操作。排序后,可以通过调用reverse方法来颠倒项目的顺序。两个排序列表可以合并,这涉及从参数列表中获取项目并按顺序插入到调用列表中:

    list<int> num1 { 2,7,1,8,2,8 }; // digits of Euler's number 
    list<int> num2 { 3,1,4,5,6,8 }; // digits of pi 
    num1.sort();                    // {1,2,2,7,8,8} 
    num2.sort();                    // {1,3,4,5,6,8} 
    num1.merge(num2);               // {1,1,2,2,3,4,5,6,7,8,8,8}

合并两个列表可能会导致重复项,可以通过调用unique方法来移除这些重复项:

    num1.unique(); // {1,2,3,4,5,6,7,8}

前向列表

正如其名称所示,forward_list类类似于list类,但它只允许从列表前面插入和移除项目。这也意味着与该类一起使用的迭代器只能递增;编译器将拒绝允许您递减这样的迭代器。该类具有list方法的子集,因此具有push_frontpop_frontemplace_front方法,但没有相应的_back方法。它还实现了一些其他方法,因为列表项目只能以前向方式访问,这意味着插入将发生在现有项目之后,因此该类实现了insert_afteremplace_after

同样,您可以从列表开头移除项目(pop_front)或在指定项目之后移除项目(erase_after),或者告诉类在列表中以前向方式迭代并移除具有特定值的项目(removeremove_if):

    forward_list<int> euler { 2,7,1,8,2,8 }; 
    euler.push_front(-1);       // { -1,2,7,1,8,2,8 } 
    auto it = euler.begin();    // iterator points to -1 
    euler.insert_after(it, -2); // { -1,-2,2,7,1,8,2,8 } 
    euler.pop_front();          // { -2,2,7,1,8,2,8 } 
    euler.remove_if([](int i){return i < 0;}); 
                                // { 2,7,1,8,2,8 }

在前面的代码中,euler被初始化为 Euler 数的数字,并且值为-1被推到最前面。接下来,获得一个指向容器中第一个值的迭代器;也就是说,指向值为-1的位置。在迭代器的位置之后插入了值为-2;也就是说,在值为-1之后插入了-2。最后两行展示了如何移除项目;pop_front移除容器前面的项目,remove_if将移除满足谓词的项目(在这种情况下,项目小于零时)。

向量

vector类具有动态数组的行为;也就是说,可以对项目进行索引随机访问,并且随着插入更多项目,容器将会增长。您可以使用初始化列表创建vector对象,并使用指定数量的项目副本。您还可以基于另一个容器中的值创建vector,方法是传递指示该容器中项目范围的迭代器。您可以通过提供容量作为构造函数参数来创建具有预定大小的向量,并且容器中将创建指定数量的默认项目。如果在以后的阶段需要指定容器大小,可以调用reserve方法来指定最小大小,或者resize方法,这可能意味着删除多余的项目或根据现有vector对象是更大还是更小来创建新项目。

当您向vector容器插入项目并且没有分配足够的内存时,容器将分配足够的内存。这将涉及分配新内存,将现有项目复制到新内存中,创建新项目,最后销毁旧项目的副本并释放旧内存。显然,如果您知道项目的数量,并且知道vector容器没有足够的空间来容纳它们而需要新的分配,您应该通过调用reserve方法指示需要多少空间。

除了构造函数之外插入项目是简单的。您可以使用push_back在末尾插入项目(假设不需要分配,这是一个快速操作),还有pop_back来移除最后一个项目。您还可以使用assign方法清除整个容器并插入指定的项目(多个相同项目,项目的初始化列表,或使用迭代器指定的另一个容器中的项目)。与list对象一样,您可以清除整个vector,在特定位置擦除项目,或在指定位置插入项目。但是,没有相当于remove方法来删除具有特定值的项目。

使用vector类的主要原因是使用at方法或[]索引运算符进行随机访问:

   vector<int> distrib(10); // ten intervals 
   for (int count = 0; count < 1000; ++count) 
   { 
      int val = rand() % 10; 
      ++distrib[val]; 
   } 
   for (int i : distrib) cout << i << endl;

第一行创建了一个具有十个项目的vector,然后在循环中每次调用 C 运行时函数rand一千次,以获得一个在 0 和 32767 之间的伪随机数。使用模运算来获得大约在 0 和 9 之间的随机数。然后将这个随机数用作distrib对象的索引,以选择指定的项目,然后递增。最后,分布被打印出来,正如你所期望的那样,这给出了每个项目大约 100 的值。

这段代码依赖于[]运算符返回项目的引用这一事实,这就是为什么可以以这种方式递增项目。可以使用[]运算符读取和写入容器中的项目。容器通过beginend方法提供迭代器访问,并且(因为它们被容器适配器所需)提供frontback方法。

vector对象可以保存具有复制构造函数和赋值运算符的任何类型,这意味着所有内置类型。就目前而言,一个bool项目的vector将是一种浪费内存,因为布尔值可以存储为单个位,并且编译器将把bool视为整数(32 位)。标准库为bool专门化了vector类,以更有效地存储项目。然而,尽管这个类乍一看是一个好主意,问题在于,由于容器将布尔值存储为位,这意味着[]运算符不会返回对bool的引用(而是返回一个像布尔值一样行为的对象)。

如果您想要保存布尔值并对其进行操作,只要在编译时知道有多少项目,bitset类可能是更好的选择。

双端队列

名称deque意味着双端队列,这意味着它可以从两端增长,尽管您可以在中间插入项目,但这更昂贵。作为队列,这意味着项目是有序的,但是,因为项目可以从任一端放入队列,所以顺序不一定是您将项目放入容器的顺序。

deque的接口类似于vector,因此您可以使用at函数和[]运算符进行迭代访问以及随机访问。与vector一样,您可以使用push_backpop_backback方法访问deque容器的末尾的项目,但与vector不同的是,您还可以使用push_frontpop_frontfront方法访问deque容器的前端。尽管deque类有方法允许您在容器中插入和删除项目,并且resize,但这些是昂贵的操作,如果您需要使用它们,那么您应该重新考虑使用这种容器类型。此外,deque类没有方法来预先分配内存,因此,潜在地,当您向此容器添加项目时,它可能会导致内存分配。

关联容器

使用类似 C 的“数组”或“向量”,每个项目都与其数字索引相关联。早些时候,在“向量”部分的一个示例中,这在分布的十分位数中被利用,方便地,分布被分割成了十个数据的十分位数。

关联容器允许您提供非数字索引的键,并且您可以将值与它们关联起来。当您将键值对插入容器时,它们将被排序,以便容器随后可以通过其键有效地访问值。通常,这个顺序对您来说不重要,因为您不会使用容器按顺序访问项目,而是会通过它们的键访问值。典型的实现将使用二叉树或哈希表,这意味着根据其键查找项目是一个快速的操作。

对于有序容器,比如map,将使用<(小于谓词)在容器中的现有键和键之间进行比较。默认谓词意味着将比较键,如果这是一个智能指针,那么将比较并用于排序的将是智能指针对象,而不是它们包装的对象。在这种情况下,您将需要编写自己的谓词来执行适当的比较,并将其作为模板参数传递。

这意味着插入或删除项目通常是昂贵的,并且键被视为不可变,因此您不能为项目更改它。对于所有关联容器,没有删除方法,但有擦除方法。但是,对于那些保持项目排序的容器,擦除项目可能会影响性能。

有几种类型的关联容器,主要区别在于它们如何处理重复的键以及发生的排序级别。map类具有按唯一键排序的键值对,因此不允许重复的键。如果要允许重复的键,则可以使用multimap类。set类本质上是一个键与值相同的映射,同样不允许重复。multiset类允许重复。

在关联类中,键与值相同可能看起来很奇怪,但在本节中包含该类的原因是因为,与map类一样,set类具有类似的接口来查找值。与map类类似,set类在查找项目时速度很快。

映射和多重映射

map容器存储两个不同的项目,一个键和一个值,并且根据键以排序顺序维护项目。排序的map意味着快速定位项目。该类具有与其他容器相同的接口来添加项目:您可以通过构造函数将它们放入容器中,也可以使用成员方法insertemplace。您还可以通过迭代器访问项目。当然,迭代器提供对单个值的访问,因此对于map来说,这将是对具有键和值的pair对象的访问:

    map<string, int> people; 
    people.emplace("Washington", 1789); 
    people.emplace("Adams", 1797); 
    people.emplace("Jefferson", 1801); 
    people.emplace("Madison", 1809); 
    people.emplace("Monroe", 1817); 

    auto it = people.begin(); 
    pair<string, int> first_item = *it; 
    cout << first_item.first << " " << first_item.second << endl;

emplace的调用将项目放入map中,其中键是string(总统的名字),值是int(总统开始任期的年份)。然后,代码获取容器中第一个项目的迭代器,并通过解引用迭代器访问项目以给出pair对象。由于项目按排序顺序存储在map中,第一个项目将设置为"Adams"。您还可以将项目作为pair对象插入,可以是对象,也可以通过insert方法使用另一个容器中的pair对象的迭代器。

大多数的emplaceinsert方法将返回以下形式的pair对象,其中iterator类型与map相关:

    pair<iterator, bool>

您可以使用此对象测试两件事。首先,bool指示插入是否成功(如果具有相同键的项目已在容器中,则插入将失败)。其次,pairiterator部分指示新项目的位置,或者指示不会被替换的现有项目的位置(并且将导致插入失败)。

失败取决于等价而不是相等。如果具有等效于您要插入的项目的键的项目,则插入将失败。等效性的定义取决于与map对象一起使用的比较器谓词。因此,如果map使用谓词comp,则两个项目ab之间的等效性是通过测试!comp(a,b) && !comp(b,a)来确定的。这与测试(a==b)不同。

假设先前的map对象,您可以这样做:

    auto result = people.emplace("Adams", 1825); 
    if (!result.second) 
       cout << (*result.first).first << " already in map" << endl;

result变量中的第二个项目进行测试,以查看插入是否成功,如果没有,则第一个项目是指向pair<string,int>的迭代器,这是现有项目,代码对迭代器进行解引用以获取pair对象,然后打印出第一个项目,这是键(在这种情况下是人的姓名)。

如果您知道项目应该放在map中的位置,那么可以调用emplace_hint

    auto result = people.emplace("Monroe", 1817); 
    people.emplace_hint(result.first, "Polk", 1845);

在这里,我们知道PolkMonroe之后,所以我们可以将Monroe的迭代器作为提示。该类通过迭代器访问项目,因此您可以使用基于迭代器访问的范围for(它基于迭代器访问):

    for (pair<string, int> p : people) 
    { 
        cout << p.first << " " << p.second << endl; 
    }

此外,还可以使用at方法和[]运算符访问单个项目。在这两种情况下,类将搜索具有提供的键的项目,如果找到项目,则返回对项目值的引用。在没有指定键的项目的情况下,at方法和[]运算符的行为是不同的。

如果键不存在,at方法将引发异常;如果[]运算符找不到指定的键,它将使用该键创建一个新项目,并调用值类型的默认构造函数。如果键存在,[]运算符将返回对值的引用,因此您可以编写如下代码:

    people["Adams"] = 1825; 
    people["Jackson"] = 1829;

第二行的行为与您期望的相同:没有键为Jackson的项目,因此map将创建一个具有该键的项目,通过调用值类型(int)的默认构造函数进行初始化(因此值初始化为零),然后返回对此值的引用,该值被赋予1829的值。然而,第一行将查找Adams,看到有一个项目,并返回对其值的引用,然后将其赋予1825的值。没有迹象表明项目的值已更改,而不是插入了新项目。在某些情况下,您可能希望出现这种行为,但这并不是本代码的意图,显然需要一个允许重复键的关联容器(例如multimap)。此外,在这两种情况下,都会搜索键,返回引用,然后执行赋值。请注意,虽然以这种方式插入项目是有效的,但在容器中放置新的键值对更有效,因为您不需要进行额外的赋值。

一旦您填写了map,您就可以使用以下方法搜索值:

  • at方法,传递一个键并返回该键的值的引用

  • []运算符,当传递一个键时,返回该键的值的引用

  • find函数将使用模板中指定的谓词(与后面提到的全局find函数不同),并将为您提供到整个项目的pair对象的迭代器

  • begin方法将为您提供到第一个项目的迭代器,end方法将为您提供到最后一个项目之后的迭代器

  • lower_bound方法返回一个迭代器,该迭代器指向具有与您传递的键相等或更大的键的项目

  • upper_bound方法返回一个迭代器,该迭代器指向具有大于提供的键的键的第一个项目

  • equal_range方法返回pair对象中的下限和上限值

集合和多重集合

集合的行为就像它们是映射一样,但键与值相同;例如,以下内容:

    set<string> people{ 
       "Washington","Adams", "Jefferson","Madison","Monroe",  
       "Adams", "Van Buren","Harrison","Tyler","Polk"}; 
    for (string s : people) cout << s << endl;

这将按字母顺序打印出个人,因为有两个名为Adams的项目,而set类将拒绝重复。当项目插入到集合中时,它将被排序,而在这种情况下,顺序是由比较两个string对象的词典顺序确定的。如果您想允许重复,以便将十个人放入容器中,那么您应该使用multiset

map一样,您不能更改容器中项目的键,因为键用于确定排序。对于set,键与值相同,这意味着您根本不能更改项目。如果意图是执行查找,那么最好使用排序的vectorset的内存分配开销比vector大。潜在地,如果搜索是顺序的,set容器上的查找可能比vector容器上的查找更快,但如果使用binary_search调用(稍后在排序项目部分中解释),它可能比关联容器更快。

set类的接口是map类的受限版本,因此您可以在容器中insertemplace项目,将其分配给另一个容器中的值,并且可以使用迭代器访问(beginend方法)。

由于没有明确的键,这意味着find方法寻找值,而不是键(类似地,边界方法也是如此;例如,equal_range)。没有at方法,也没有[]运算符。

无序容器

mapset类允许您快速查找对象,这是由这些类以排序顺序保存项目来实现的。如果您遍历项目(从beginend),那么您将按排序顺序获取这些项目。如果您想要一系列键值范围内的对象,可以调用lower_boundupper_bound方法,以获取适当键范围的迭代器。

这些关联容器的两个重要特性是查找和排序。在某些情况下,值的实际顺序并不重要,您想要的行为是高效的查找。在这种情况下,您可以使用mapset类的unordered_版本。由于顺序不重要,这些是使用哈希表实现的。

特殊目的容器

到目前为止描述的容器是灵活的,可以用于各种目的。标准库提供了具有特定目的的类,但由于它们是通过包装其他类实现的,因此它们被称为容器适配器。例如,deque对象可以通过将对象推到deque的后面(使用push_back)并使用front方法从队列的前面访问对象(并使用pop_front删除它们)来用作先进先出FIFO)队列。标准库实现了一个名为queue的容器适配器,它具有这种 FIFO 行为,并且基于deque类。

    queue<int> primes; 
    primes.push(1); 
    primes.push(2); 
    primes.push(3); 
    primes.push(5); 
    primes.push(7); 
    primes.push(11); 
    while (primes.size() > 0) 
    { 
        cout << primes.front() << ","; 
        primes.pop(); 
    } 
    cout << endl; // prints 1,2,3,5,7,11

您可以使用push将项目推入队列,并使用pop将其移除,并使用front方法访问下一个项目。可以由此适配器包装的标准库容器实现push_backpop_frontfront方法。也就是说,项目被放入容器的一端,并且从另一端访问(和移除)。

后进先出(LIFO)容器将项目放入并从同一端访问(和移除)项目。同样,deque 对象可以通过使用 push_back 推送项目,使用 front 访问项目,并使用 pop_back 方法删除它们来实现此行为。标准库提供了一个适配器类称为 stack 来提供这种行为。它有一个名为 push 的方法将项目推入容器,一个名为 pop 的方法来移除项目,但是奇怪的是,你使用 top 方法访问下一个项目,尽管它是使用包装容器的 back 方法实现的。

适配器类 priority_queue,尽管名字叫这个,但它的使用方式类似于 stack 容器;也就是说,使用 top 方法访问项目。容器确保当一个项目被推入时,队列的顶部始终是具有最高优先级的项目。一个谓词(默认为<)用于对队列中的项目进行排序。例如,我们可以有一个聚合类型,它包含一个任务的名称和你必须完成任务的优先级,与其他任务相比:

    struct task 
    { 
    string name; 
    int priority; 
    task(const string& n, int p) : name(n), priority(p) {} 
    bool operator <(const task& rhs) const { 
        return this->priority < rhs.priority; 
        } 
    };

聚合类型很简单;它有两个数据成员,这些成员由构造函数初始化。为了能够对任务进行排序,我们需要能够比较两个任务对象。一个选项(前面提到过)是定义一个单独的谓词类。在这个例子中,我们使用默认谓词,文档中说将是 less,它根据<运算符比较项目。为了能够使用默认谓词,我们为 task 类定义<运算符。现在我们可以将任务添加到 priority_queue 容器中:

    priority_queue<task> to_do; 
    to_do.push(task("tidy desk", 1)); 
    to_do.push(task("check in code", 10)); 
    to_do.push(task("write spec", 8)); 
    to_do.push(task("strategy meeting", 8)); 

    while (to_do.size() > 0) 
    { 
        cout << to_do.top().name << " " << to_do.top().priority << endl; 
        to_do.pop(); 
    }

这段代码的结果是:

    check in code 10
write spec 8
strategy meeting 8
tidy desk 1

队列根据 priority 数据项对任务进行了排序,并且 top 和 pop 方法的组合调用按优先级顺序读取项目并从队列中删除它们。具有相同优先级的项目按照它们被推入的顺序放入队列中。

使用迭代器

到目前为止,在本章中,我们已经指出容器通过迭代器访问项目。这意味着迭代器只是指针,这是有意为之的,因为迭代器的行为类似于指针。但是,它们通常是迭代器类的对象(请参阅头文件)。所有迭代器都具有以下行为:

运算符行为
*访问当前位置的元素
++向前移动到下一个元素(通常你会使用前缀运算符)(只有在迭代器允许向前移动时才会这样)
--向后移动到上一个元素(通常你会使用前缀运算符)(只有在迭代器允许向后移动时才会这样)
==和!=比较两个迭代器是否在相同位置
=分配一个迭代器

与 C++指针不同,迭代器假定数据在内存中是连续的,迭代器可以用于更复杂的数据结构,例如链表,其中项目可能不是连续的。无论底层存储机制如何,++和--运算符都能正常工作。

头文件声明了 next 全局函数,它将增加一个迭代器,以及 advance 函数,它将按指定数量的位置更改迭代器(向前或向后取决于参数是否为负数以及迭代器允许的方向)。还有一个 prev 函数,用于将迭代器减少一个或多个位置。distance 函数可用于确定两个迭代器之间有多少项。

所有容器都有一个begin方法,返回第一项的迭代器,和一个end方法,返回最后一项之后的迭代器。这意味着您可以通过调用begin然后递增迭代器直到它具有从end返回的值来遍历容器中的所有项。迭代器上的*运算符可以访问容器中的元素,如果迭代器是可读写的(如果从 begin 方法返回的话),这意味着该项可以被更改。

容器还有cbegincend方法,它们将返回一个只读访问元素的常量迭代器:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    const auto it = primes.begin(); // const has no effect 
    *it = 42; 
    auto cit = primes.cbegin(); 
    *cit = 1;                       // will not compile

这里的const没有效果,因为变量是auto,类型是从用于初始化变量的项中推导出来的。cbegin方法被定义为返回一个const迭代器,因此您不能更改它所引用的项。

begincbegin方法返回前向迭代器,因此++运算符将迭代器向前移动。容器还可以支持反向迭代器,其中rbegin是容器中的最后一项(即end返回的位置之前的项),rend是第一项之前的位置。(还有crbegincrend,它们返回const迭代器。)重要的是要意识到反向迭代器的++运算符是向移动的,就像以下示例中所示:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    auto it = primes.rbegin(); 
    while (it != primes.rend()) 
    { 
        cout << *it++ << " "; 
    } 
    cout << endl; // prints 13,11,7,5,4,3,2,1

++运算符根据应用于的迭代器类型来递增迭代器。重要的是要注意,这里使用!=运算符来确定循环是否应该结束,因为!=运算符将在所有迭代器上定义。

这里忽略了迭代器类型,使用了auto关键字。实际上,所有容器都会为它们使用的所有迭代器类型定义typedef,所以在前面的例子中我们可以使用以下方式:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    vector<int>::iterator it = primes.begin();

允许前向迭代的容器将为iteratorconst_iterator定义typedef,允许反向迭代的容器将为reverse_iteratorconst_reverse_iterator定义typedef

为了完整起见,容器还将为返回指向元素的指针的方法定义pointerconst_pointertypedef,并为返回元素引用的方法定义referenceconst_referencetypedef。这些类型定义使您能够编写通用代码,其中您不知道容器中的类型,但代码仍然能够声明正确类型的变量。

尽管它们看起来像指针,但迭代器通常由类实现。这些类型可能只允许单向迭代:前向迭代器只有++运算符,反向迭代器有-运算符,或者类型可以允许双向迭代(双向迭代器),因此它们实现了++--运算符。例如,listsetmultisetmapmultimap类上的迭代器是双向的。vectordequearraystring类具有允许随机访问的迭代器,因此这些迭代器类型具有与双向迭代器相同的行为,但也具有指针的算术运算,因此它们可以一次更改多个项的位置。

输入和输出迭代器

顾名思义,输入迭代器只能向前移动并具有读取访问权限,而输出迭代器只能向前移动但具有写入访问权限。这些迭代器没有随机访问,也不允许向后移动。例如,输出流可以与输出迭代器一起使用:您将数据项分配给解引用的迭代器,以便将该数据项写入流中。类似地,输入流可以具有输入迭代器,您可以解引用迭代器以访问流中的下一个项。这种行为意味着对于输出迭代器,解引用运算符 (*) 的唯一有效用法是在赋值的左侧。检查迭代器的值是否等于 != 是没有意义的,并且您不能检查通过输出迭代器分配值是否成功。

例如,transform 函数接受三个迭代器和一个函数。前两个迭代器是输入迭代器,指示要通过函数转换的项目范围。结果将放在项目范围内(与输入迭代器的范围大小相同),第一个由第三个迭代器指示,这是一个输出迭代器。可以按以下方式之一执行此操作:

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    results.resize(data.size()); 
    transform( 
       data.begin(), data.end(),  
       results.begin(), 
       [](int x){ return x*x; } );

这里的 beginend 方法返回 data 容器上的迭代器,可以安全地用作输入迭代器。results 容器上的 begin 方法只能用作输出迭代器,只要容器有足够分配的项目,这在此代码中是成立的,因为它们已经用 resize 分配了。然后,该函数将通过将输入项传递给最后一个参数中给定的 lambda 函数(简单地返回值的平方)来转换每个输入项。重要的是要重新评估这里发生了什么;transform 函数的第三个参数是输出迭代器,这意味着您应该期望该函数通过此迭代器写入值。

这段代码有效,但需要额外的步骤来分配空间,并且您需要额外分配默认对象以便覆盖它们。还要注意的是,输出迭代器不一定要指向另一个容器。只要它指向可以写入的范围,它就可以指向相同的容器:

    vector<int> vec{ 1,2,3,4,5 }; 
    vec.resize(vec.size() * 2); 
    transform(vec.begin(), vec.begin() + 5, 
       vec.begin() + 5, [](int i) { return i*i; });

vec 容器被调整大小,以便有空间存放结果。要转换的值范围是从第一个项目到第五个项目(vec.begin() + 5 是下一个项目),写入转换值的位置是第六到第十个项目。如果打印出向量,您将得到 {1,2,3,4,5,1,4,9,16,25}

另一种输出迭代器是插入器。back_inserter 用于具有 push_back 的容器,front_inserter 用于具有 push_front 的容器。顾名思义,插入器在容器上调用 insert 方法。例如,您可以像这样使用 back_inserter

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    transform( 
       data.begin(), data.end(),  
       back_inserter(results), 
       [](int x){ return x*x; } ); // 1,4,9,16,25

转换的结果将使用从 back_inserter 类创建的临时对象插入到 results 容器中。使用 back_inserter 对象可以确保当 transform 函数通过迭代器写入时,该项将插入到包装容器中,使用 push_back。请注意,结果容器应与源容器不同。

如果要以相反顺序获取值,那么如果容器支持 push_front(例如 deque),则可以使用 front_insertervector 类没有 push_front 方法,但它具有反向迭代器,因此可以使用它们代替:

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    transform( 
 data.rbegin(), data.rend(), 
       back_inserter(results), 
       [](int x){ return x*x; } ); // 25,16,9,4,1

要反转结果的顺序,您只需将 begin 更改为 rbegin,将 end 更改为 rend

流迭代器

这些是 <iterators> 中的适配器类,可用于从输入流读取项目或将项目写入输出流。例如,到目前为止,我们已经通过范围 for 循环使用迭代器来打印容器的内容:

    vector<int> data { 1,2,3,4,5 }; 
    for (int i : data) cout << i << " "; 
    cout << endl;

相反,您可以基于cout创建一个输出流迭代器,这样int值将通过这个迭代器使用流操作符<<写入cout流。要打印出一个int值的容器,您只需将容器复制到输出迭代器即可:

    vector<int> data { 1,2,3,4,5 }; 
    ostream_iterator<int> my_out(cout, " "); 
    copy(data.cbegin(), data.cend(), my_out); 
    cout << endl;

ostream_iterator类的第一个参数是它将适配的输出流,可选的第二个参数是在每个项目之间使用的分隔符字符串。copy函数(在<algorithm>中)将复制由输入迭代器指示的范围中的项目,作为前两个参数传递,到作为最后一个参数传递的输出迭代器中。

类似地,还有一个istream_iterator类,它将包装一个输入流对象并提供一个输入迭代器。这个类将使用流>>操作符来提取指定类型的对象,这些对象可以通过流迭代器读取。然而,从流中读取数据比写入更复杂,因为必须检测输入流中是否还有更多数据供迭代器读取(文件结束情况)。

istream_iterator类有两个构造函数。一个构造函数有一个参数,即要读取的输入流,另一个构造函数,即默认构造函数,没有参数,用于创建一个流结束迭代器。流结束迭代器用于指示流中没有更多数据:

    vector<int> data; 
    copy( 
       istream_iterator<int>(cin), istream_iterator<int>(), 
       back_inserter(data)); 

    ostream_iterator<int> my_out(cout, " "); 
    copy(data.cbegin(), data.cend(), my_out); 
    cout << endl;

第一次调用copy提供两个输入迭代器作为第一个参数,并提供一个输出迭代器。该函数将数据从第一个迭代器复制到最后一个参数中的输出迭代器。由于最后一个参数是由back_inserter创建的,这意味着项目将插入到vector对象中。输入迭代器基于输入流(cin),因此copy函数将从控制台读取int值(每个值由空格分隔),直到没有更多可用的值(例如,如果您按CTRL + Z结束流,或者输入一个非数字项)。由于您可以使用迭代器给定的值范围初始化容器,因此可以使用istream_iterator作为构造函数参数:

    vector<int> data {  
       istream_iterator<int>(cin), istream_iterator<int>() };

在这里,构造函数使用初始化列表语法调用;如果使用括号,编译器将将其解释为函数的声明!

如前所述,istream_iterator将使用流的>>操作符从流中读取指定类型的对象,这个操作符使用空白来分隔项目(因此它只忽略所有空白)。如果读取一个string对象的容器,那么您在控制台上键入的每个单词都将成为容器中的一个项目。string是一个字符的容器,它也可以使用迭代器进行初始化,因此您可以尝试使用istream_iterator从控制台向string输入数据:

    string data { 
            istream_iterator<char>(cin), istream_iterator<char>() };

在这种情况下,流是cin,但它也可以很容易地是一个指向文件的ifstream对象。问题在于cin对象将剥离空白,因此string对象将包含您键入的除空白之外的所有内容,因此不会有空格和换行符。

这个问题是由istream_iterator使用流的>>操作符引起的,只能通过使用另一个类istreambuf_iterator来避免。

    string data { 
        istreambuf_iterator<char>(cin), istreambuf_iterator<char>() };

这个类从流中读取每个字符,并将每个字符复制到容器中,而不进行>>的处理。

使用 C 标准库的迭代器

C 标准库通常需要指向数据的指针。例如,当 C 函数需要一个字符串时,它将需要一个指向包含字符串的字符数组的const char*指针。C++标准库已经被设计成允许您使用其类与 C 标准库;事实上,C 标准库是 C++标准库的一部分。对于string对象,解决方案很简单:当您需要一个const char*指针时,您只需在string对象上调用c_str方法。

以连续内存存储数据的容器(arraystringdata)有一个名为data的方法,它将容器的数据作为 C 数组进行访问。此外,这些容器有[]操作符访问它们的数据,因此您也可以将第一个项目的地址视为&container[0](其中container是容器对象),就像您对 C 数组一样。但是,如果容器为空,这个地址将是无效的,因此在使用之前,您应该调用empty方法。这些容器中的项目数是从size方法返回的,因此对于任何需要指向 C 数组开头和其大小的指针的 C 函数,您可以使用&container[0]size方法的值来调用它。

你可能会被诱惑去调用容器的begin函数来获取具有连续内存的容器的开始部分,但这将返回一个迭代器(通常是一个对象)。因此,要获取第一个项目的 C 指针,您应该调用&*begin;也就是说,解引用从begin函数返回的迭代器以获取第一个项目,然后使用地址运算符获取其地址。坦率地说,&container[0]更简单更易读。

如果容器不是以连续内存存储其数据(例如dequelist),那么您可以通过简单地将数据复制到临时向量中来获得 C 指针。

    list<int> data; 
    // do some calculations and fill the list 
    vector<int> temp(data.begin(), data.end()); 
    size_t size = temp.size(); // can pass size to a C function 
    int *p = &temp[0];         // can pass p to a C function

在这种情况下,我们选择使用list,并且该例程将操作data对象。在例程的后面,这些值将被传递给一个 C 函数,因此list用于初始化一个vector对象,并且这些值是从vector中获取的。

算法

标准库在<algorithm>头文件中有大量的通用函数集合。通用意味着它们通过迭代器访问数据,而不知道迭代器指的是什么,这意味着您可以编写通用代码来适用于任何适当的容器。但是,如果您知道容器类型,并且该容器有一个成员方法来执行相同的操作,您应该使用该成员。

项目的迭代

<algorithm>中的许多例程将接受范围并迭代这些范围执行某些操作。正如其名称所示,fill函数将使用一个值填充容器。该函数需要两个迭代器来指定范围和一个将放入容器每个位置的值:

    vector<int> vec; 
    vec.resize(5); 
    fill(vec.begin(), vec.end(), 42);

由于fill函数将被调用用于一个范围,这意味着您必须传递迭代器到已经有值的容器,这就是为什么这段代码调用resize方法的原因。这段代码将42的值放入容器的每个项目中,因此当它完成后,vector包含{42,42,42,42,42}。这个函数的另一个版本叫做fill_n,它通过单个迭代器到范围的开始和范围中的项目数来指定范围。

generate函数类似,但是,它不是一个单一的值,而是一个函数,可以是函数、函数对象或 lambda 表达式。调用该函数来提供容器中的每个项目,因此它没有参数,并返回由迭代器访问的类型的对象:

    vector<int> vec(5); 
    generate(vec.begin(), vec.end(),  
        []() {static int i; return ++i; });

再次,您必须确保generate函数传递的是已经存在的范围,这段代码通过将初始大小作为构造函数参数来实现这一点。在这个例子中,lambda 表达式有一个static变量,每次调用都会增加,这意味着在generate函数完成后,vector包含{1,2,3,4,5}。这个函数的另一个版本叫做generate_n,它通过单个迭代器到范围的开始和范围中的项目数来指定范围。

for_each函数将迭代由两个迭代器提供的范围,并且对于范围中的每个项目,调用指定的函数。这个函数必须有一个与容器中的项目相同类型的单一参数:

    vector<int> vec { 1,4,9,16,25 }; 
    for_each(vec.begin(), vec.end(),  
         [](int i) { cout << i << " "; }); 
    cout << endl;

for_each函数遍历迭代器指定的所有项目(在本例中是整个范围),解引用迭代器,并将项目传递给函数。此代码的效果是打印容器的内容。函数可以按值(在本例中)或按引用传递项目。如果通过引用传递项目,则函数可以更改项目:

    vector<int> vec { 1,2,3,4,5 }; 
    for_each(vec.begin(), vec.end(),  
         [](int& i) { i *= i; });

调用此代码后,vector中的项目将被替换为这些项目的平方。如果使用函数对象或 lambda 表达式,可以传递一个容器来捕获函数的结果;例如:

    vector<int> vec { 1,2,3,4,5 }; 
    vector<int> results; 
    for_each(vec.begin(), vec.end(),  
         &results { results.push_back(i*i); });

在这里,声明了一个容器来接受对 lambda 表达式的每次调用的结果,并且通过捕获将变量按引用传递给表达式。

请回顾第三章中的使用函数,方括号中包含在表达式外声明的捕获变量的名称。一旦捕获,这意味着表达式能够访问该对象。

在此示例中,每次迭代(i*i)的结果都被推送到捕获的集合中,以便稍后存储结果。

transform函数有两种形式;它们都提供一个函数(指针、函数对象或 lambda 表达式),并且它们都有一个通过迭代器传递的容器中项目的输入范围。在这方面,它们类似于for_eachtransform函数还允许您传递一个用于存储函数结果的容器的迭代器。函数必须具有与输入迭代器引用的类型相同的单个参数,并且必须返回由输出迭代器访问的类型。

transform的另一个版本使用函数来组合两个范围中的值,因此这意味着函数必须具有两个参数(将是两个迭代器中的相应项目),并返回输出迭代器的类型。您只需要在其中一个输入范围中提供所有项目的完整范围,因为假定另一个范围至少与之一样大,因此您只需要提供第二个范围的开始迭代器:

    vector<int> vec1 { 1,2,3,4,5 }; 
    vector<int> vec2 { 5,4,3,2,1 }; 
    vector<int> results; 
    transform(vec1.begin(), vec1.end(), vec2.begin(), 
       back_inserter(results), [](int i, int j) { return i*j; });

获取信息

一旦容器中有值,就可以调用函数来获取有关这些项目的信息。count函数用于计算范围中具有指定值的项目数:

    vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    auto number = count(planck.begin(), planck.end(), 6);

此代码将返回值3,因为容器中有三个6的副本。函数的返回类型是容器的difference_type类型,本例中将是intcount_if函数的工作方式类似,但您传递一个谓词,该谓词接受一个参数(容器中的当前项目)并返回一个bool,指定是否正在计数的值。

count函数计算特定值的出现次数。如果要汇总所有值,则可以使用<numeric>中的accumulate函数。这将遍历范围,访问每个项目,并保持所有项目的累积总和。

求和将使用类型的+运算符进行,但也有一个版本,它接受一个二元函数(容器类型的两个参数并返回相同类型),指定将两个这样的类型相加时会发生什么。

all_ofany_ofnone_of函数都接受一个谓词,该谓词具有与容器相同类型的单个参数;它们还接受指示迭代范围的迭代器,测试谓词对每个项目的结果。all_of函数仅在所有项目的谓词为true时返回trueany_of函数在至少一个项目的谓词为true时返回true,而none_of函数仅在所有项目的谓词为false时返回true

比较容器

如果您有两个数据容器,有各种方法可以比较它们。对于每种容器类型,都定义了<<===!=>>=运算符。==!=运算符比较容器,无论它们有多少项目以及这些项目的值。因此,如果项目具有不同数量的项目、不同的值或两者都有,则它们不相等。其他比较更喜欢值而不是项目的数量:

    vector<int> v1 { 1,2,3,4 }; 
    vector<int> v2 { 1,2 }; 
    vector<int> v3 { 5,6,7 }; 
    cout << boolalpha; 
    cout << (v1 > v2) << endl; // true 
    cout << (v1 > v3) << endl; // false

在第一个比较中,两个向量具有相似的项目,但v2的项目较少,因此v1“大于”v2。在第二种情况下,v3的值大于v1,但数量较少,因此v3大于v1

您还可以使用equal函数比较范围。这需要传递两个范围(假定它们的大小相同,因此只需要第二个范围的开始迭代器),并使用==运算符或用户提供的谓词比较两个范围中的相应项目。只有在所有这样的比较都为true时,函数才会返回true。类似地,mismatch函数比较两个范围中的相应项目。但是,此函数返回一个pair对象,其中包含两个范围中的迭代器,用于第一个不相同的项目。您还可以提供一个比较函数。is_permutation类似于它比较两个范围中的值,但是如果两个范围具有相同的值但不一定是相同的顺序,则返回true

更改项目

reverse 函数作用于容器中的范围,并颠倒项目的顺序;这意味着迭代器必须是可写的。copycopy_n函数将一个范围中的每个项目从一个方向复制到另一个方向;对于copy,输入范围由两个输入迭代器给出,对于copy_n,范围是一个输入迭代器和项目的计数。copy_backward函数将复制项目,从范围的末尾开始,以便输出范围中的项目与原始项目的顺序相同。这意味着输出迭代器将指示要复制到的范围的结束。您还可以只复制满足谓词指定的某些条件的项目。

  • reverse_copy函数将以与输入范围相反的顺序创建副本;实际上,该函数通过原始范围向后迭代并将项目向前复制到输出范围。

  • 尽管名称如此,movemove_backward函数在语义上等同于copycopy_backward函数。因此,在以下情况下,原始容器在操作后将具有相同的值:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> result(4);          // we want 4 items 
        auto it1 = planck.begin();      // get the first position 
        it1 += 2;                       // move forward 2 places 
        auto it2 = it1 + 4;             // move 4 items 
        move(it1, it2, result.begin()); // {2,6,0,7}
  • 此代码将从第一个容器复制四个项目到第二个容器,从第三个位置的项目开始。

  • remove_copyremove_copy_if函数遍历源范围,并复制除指定值之外的项目。

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> result; 
        remove_copy(planck.begin(), planck.end(),  
            back_inserter(result), 6);
  • 在这里,planck对象与之前一样,result对象将包含{2,0,7,0,0,4,0}remove_copy_if函数的行为类似,但是给定的是谓词而不是实际值。

  • removeremove_if函数并不完全按照它们的名称所暗示的那样。这些函数作用于单个范围,并迭代寻找特定值(remove),或将每个项目传递给将指示是否应删除项目的谓词(remove_if)。当删除项目时,容器中后面的项目会向前移动,但容器的大小保持不变,这意味着末尾的项目保持不变。remove函数的行为之所以如此,是因为它们只知道通过迭代器读取和写入项目(这对所有容器都是通用的)。要擦除项目,函数将需要访问容器的erase方法,而remove函数只能访问迭代器。

  • 如果要删除末尾的项目,则必须相应地调整容器的大小。通常,这意味着在容器上调用适当的erase方法,这是可能的,因为remove方法返回指向新末尾位置的迭代器:

        vector<int> planck { 6,6,2,6,0,7,0,0,4,0 }; 
        auto new_end = remove(planck.begin(), planck.end(), 6); 
                                             // {2,0,7,0,0,4,0,0,4,0} 
        planck.erase(new_end, planck.end()); // {2,0,7,0,0,4,0}
  • replacereplace_if函数遍历单个范围,如果值是指定值(replace)或从谓词(replace_if)返回true,则将使用指定的新值替换该项目。还有两个函数replace_copyreplace_copy_if,它们保持原始状态并将更改应用于另一个范围(类似于remove_copyremove_copy_if函数)。

  • rotate函数将范围视为末尾连接到开头,因此您可以将项目向前移动,以便当项目从末尾掉下时,它将放在第一个位置。如果要将每个项目向前移动四个位置,可以这样做:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        auto it = planck.begin(); 
        it += 4; 
        rotate(planck.begin(), it, planck.end());
  • 这个旋转的结果是{0,7,0,0,4,0,6,6,2,6}rotate_copy函数执行相同的操作,但是它不会影响原始容器,而是将项目复制到另一个容器中。

  • unique函数作用于范围,并且“删除”(以前解释的方式)与相邻项目重复的项目,并且您可以为函数提供一个谓词来测试两个项目是否相同。此函数仅检查相邻项目,因此容器中稍后的重复项将保留。如果要删除所有重复项,则应首先对容器进行排序,以便相似的项目相邻。

  • unique_copy函数将仅在项目唯一时将项目从一个范围复制到另一个范围,因此消除重复项的一种方法是在临时容器上使用此函数,然后将原始容器分配给临时容器:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> temp; 
        unique_copy(planck.begin(), planck.end(), back_inserter(temp)); 
        planck.assign(temp.begin(), temp.end());
  • 在此代码之后,planck容器将为{6,2,6,0,7,0,4,0}

  • 最后,iter_swap将交换两个迭代器指示的项目,swap_ranges函数将一个范围中的项目交换到另一个范围中(第二个范围由一个迭代器指示,并且假定它指的是与第一个范围大小相同的范围)。

查找项目

标准库具有广泛的功能来搜索项目:

  • min_element函数将返回范围中最小项的迭代器,而max_element函数将返回最大项的迭代器。这些函数接受要检查的项目范围的迭代器和一个从比较两个项目返回bool的谓词。如果不提供谓词,则将使用类型的<运算符。
        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        auto imin = min_element(planck.begin(), planck.end()); 
        auto imax = max_element(planck.begin(), planck.end()); 
        cout << "values between " << *imin << " and "<< *imax << endl;
  • iminimax值是迭代器,这就是为什么要对它们进行取消引用以获取值。如果要一次获取最小元素和最大元素,可以调用minmax_element,它将返回一个带有指向这些项目的迭代器的pair对象。顾名思义,adjacent_find函数将返回具有相同值的前两个项目的位置(您可以提供谓词来确定相同值的含义)。这使您可以搜索重复项并获取这些重复项的位置。
        vector<int> vec{0,1,2,3,4,4,5,6,7,7,7,8,9}; 
        vector<int>::iterator it = vec.begin(); 

        do 
        { 
            it = adjacent_find(it, vec.end()); 
            if (it != vec.end()) 
            {  
                cout << "duplicate " << *it << endl; 
                ++it; 
            } 
        } while (it != vec.end());
  • 此代码具有一系列数字,其中有一些重复的数字相邻。在这种情况下,有三个相邻的重复项:4后跟4,序列7,7,77后跟77后跟7do循环重复调用adjacent_find,直到它返回end迭代器,表示已搜索所有项目。找到重复对时,代码会打印出该值,然后递增下一次搜索的起始位置。

  • find函数在容器中搜索单个值,并返回指向该项的迭代器,如果找不到该值,则返回end迭代器。find_if函数接受一个谓词,并返回找到满足谓词的第一个项目的迭代器;类似地,find_if_not函数找到不满足谓词的第一个项目。

  • 有几个函数接受两个范围,一个是要搜索的范围,另一个是要查找的值。不同的函数要么查找搜索条件中的一个项目,要么查找所有项目。这些函数使用==运算符来比较容器保存的类型或谓词。

  • find_first_of函数返回它在搜索列表中找到的第一个项目的位置。search函数查找特定序列,并返回整个序列的第一个位置,而find_end函数返回整个搜索序列的最后位置。最后,search_n函数在指定容器范围内查找重复多次的值(给定值和重复次数)的序列。

排序项目

序列容器可以排序,一旦您这样做了,就可以使用方法来搜索项目,合并容器,或获取容器之间的差异。sort函数将根据提供的<运算符或谓词对范围中的项目进行排序。如果范围中有相等的项目,则排序后这些项目的顺序不能保证;如果这个顺序很重要,您应该调用stable_sort函数。如果您想保留输入范围并将排序后的项目复制到另一个范围中,您可以使用令人困惑的partial_sort_copy函数。这不是部分排序。该函数接受输入范围的迭代器和输出范围的迭代器,因此您必须确保输出范围具有合适的容量。

您可以通过调用is_sorted函数来检查范围是否已排序,如果找到不按顺序排序的项目,则会遍历所有项目并返回false,在这种情况下,您可以通过调用is_sorted_until函数找到第一个不按顺序排序的项目。

正如名称所示,partial_sort函数不会将每个项目放在与其他项目的确切顺序相关的位置。相反,它将创建两个组或分区,其中第一个分区将包含最小的项目(不一定按任何顺序),而另一个分区将包含最大的项目。您保证最小的项目在第一个分区中。要调用此函数,您传递三个迭代器,其中两个是要排序的范围,第三个是介于其他两个之间的位置,指示最小值之前的边界。

    vector<int> vec{45,23,67,6,29,44,90,3,64,18}; 
    auto middle = vec.begin() + 5; 
    partial_sort(vec.begin(), middle, vec.end()); 
    cout << "smallest items" << endl; 
    for_each(vec.begin(), middle, [](int i) {cout << i << " "; }); 
    cout << endl; // 3 6 18 23 29 
    cout << "biggest items" << endl; 
    for_each(middle, vec.end(), [](int i) {cout << i << " "; }); 
    cout << endl; // 67 90 45 64 44

在这个例子中有一个包含十个项目的向量,所以我们将middle迭代器定义为距离开头五个项目(这只是一个选择,根据您想要获得多少项目,它可能是其他值)。在这个例子中,您可以看到五个最小的项目已经被排序到了前半部分,而后半部分有最大的项目。

奇怪命名的nth_element函数类似于partial_sort。您提供一个指向第n个元素的迭代器,该函数确保范围中的前n个项目是最小的。nth_element函数比partial_sort更快,尽管您保证n元素之前的项目小于或等于n元素,但在分区内部的排序顺序没有其他保证。

partial_sortnth_element函数是分区排序函数的版本。partition函数是一个更通用的版本。您可以将一个范围和一个确定项目将被放置在哪个分区的谓词传递给这个函数。满足谓词的项目将被放置在范围的第一个分区中,其他项目将被放置在第一个分区后面的范围中。第二个分区的第一个项目称为分区点,并且它从partition函数返回,但是您可以稍后通过将迭代器传递给分区范围和谓词传递给partition_point函数来计算它。partition_copy函数也将分区值,但它将保持原始范围不变,并将值放入已经分配的范围中。这些分区函数不保证等效项目的顺序,如果这个顺序很重要,那么您应该调用stable_partitian函数。最后,您可以通过调用is_partitioned函数来确定容器是否已分区。

shuffle函数将容器中的项目重新排列为随机顺序。这个函数需要来自<random>库的均匀随机数生成器。例如,以下代码将用十个整数填充一个容器,然后以随机顺序放置它们:

    vector<int> vec; 
    for (int i = 0; i < 10; ++i) vec.push_back(i); 
    random_device rd; 
    shuffle(vec.begin(), vec.end(), rd);

堆是一个部分排序的序列,其中第一个项目始终是最大的,并且可以在对数时间内添加和删除堆中的项目。堆基于序列容器,但奇怪的是,标准库没有提供适配器类,而是需要在现有容器上使用函数调用。要从现有容器创建堆,您需要将范围迭代器传递给make_heap函数,该函数将对容器进行排序以形成堆。

然后,您可以使用其push_back方法向容器添加新项目,但每次这样做时,您都必须调用push_heap来重新排序堆。类似地,要从堆中获取项目,您需要在容器上调用front方法,然后通过调用pop_heap函数来删除项目,这可以确保堆保持有序。您可以通过调用is_heap来测试容器是否排列为堆,如果容器不完全排列为堆,您可以通过调用is_heap_until来获取到第一个不满足堆条件的项目的迭代器。最后,您可以使用sort_heap将堆排序为排序序列。

一旦您对容器进行了排序,就可以调用函数来获取有关序列的信息。lower_boundupper_bound方法已经在容器中进行了描述,并且这些函数的行为方式相同:lower_bound返回第一个具有大于或等于提供的值的元素的位置,upper_bound返回大于提供的值的下一个项目的位置。includes函数用于测试一个排序范围是否包含第二个排序范围中的项目。

set_开头的函数将两个排序序列合并为第三个容器。set_difference函数将复制第一个序列中不在第二个序列中的项目。这不是对称的操作,因为它不包括在第二个序列中但不在第一个序列中的项目。如果您想要对称差异,那么您应该调用set_symmetric_difference函数。set_intersection将复制两个序列中都存在的项目。set_union函数将合并两个序列。还有另一个函数可以合并两个序列,即merge函数。这两个函数之间的区别在于,使用set_union函数,如果一个项目同时存在于两个序列中,结果容器中只会放入一个副本,而使用merge则会在结果容器中放入两个副本。

如果一个范围是排序的,那么您可以调用equal_range函数来获取等于函数或谓词传递的值的元素范围。这个函数返回一个表示容器中值范围的迭代器对。

需要排序容器的最后一个方法是binary_search。这个函数用于测试值是否在容器中。函数传递了指示要测试的范围和一个值的迭代器,如果范围中有一个等于该值的项目,则返回true(您可以提供一个谓词来执行这个相等测试)。

使用数值库

标准库有几个类库,用于执行数值操作。在本节中,我们将涵盖两个:使用<ratio>进行编译时算术和使用<complex>进行复数运算。

编译时算术

分数是一个问题,因为有一些分数没有足够的有效数字来准确表示它们,这会导致在进一步进行算术运算时失去精度。此外,计算机是二进制的,仅将十进制小数部分转换为二进制将会失去精度。<ratio>库提供了允许您将分数表示为整数比率的对象,并将分数计算作为比率进行的类。只有在进行了所有分数算术之后,您才会将数字转换为十进制,这意味着最小化了精度损失。<ratio>库中的类执行的计算是在编译时进行的,因此编译器将捕捉到除以零和溢出等错误。

使用库很简单;您使用ratio类,并将分子和分母作为模板参数提供。分子和分母将被分解存储,您可以通过对象的numden成员访问这些值:

    ratio<15, 20> ratio; 
    cout << ratio.num << "/" << ratio.den << endl;

这将打印出3/4

分数算术是使用模板进行的(实际上,这些是ratio模板的特化)。乍一看可能有点奇怪,但很快你就会习惯的!

    ratio_add<ratio<27, 11>, ratio<5, 17>> ratio; 
    cout << ratio.num << "/" << ratio.den << endl;

这将打印出514/187(您可能需要拿一些纸来进行分数计算以确认这一点)。数据成员实际上是static成员,因此创建变量没有太多意义。此外,因为算术是使用类型而不是变量进行的,最好通过这些类型访问成员:

    typedef ratio_add<ratio<27, 11>, ratio<5, 17>> sum; 
    cout << sum::num << "/" << sum::den << endl;

现在您可以将总和类型作为您可以执行的其他操作的参数。四种二进制算术操作是使用ratio_addratio_subtractratio_multiplyratio_divide进行的。比较是通过ratio_equalratio_not_equalratio_greaterratio_greater_equalratio_lessratio_less_equal进行的。

    bool result = ratio_greater<sum, ratio<25, 19> >::value; 
    cout << boolalpha << result << endl;

这个操作测试之前执行的计算(514/187)是否大于分数25/19(是)。编译器将捕捉到除以零和溢出等错误,因此以下内容将不会编译:

    typedef ratio<1, 0> invalid; 
    cout << invalid::num << "/" << invalid::den << endl;

然而,重要的是要指出,当访问分母时,编译器将在第二行发出错误。这里还有 SI 前缀的比率的 typedef。这意味着您可以在纳米中执行计算,当您需要以米呈现数据时,可以使用nano类型来获取比率:

    double radius_nm = 10.0; 
    double volume_nm = pow(radius_nm, 3) * 3.1415 * 4.0 / 3.0; 
    cout << "for " << radius_nm << "nm " 
        "the volume is " << volume_nm << "nm3" << endl; 
    double factor = ((double)nano::num / nano::den); 
    double vol_factor = pow(factor, 3); 
    cout << "for " << radius_nm * factor << "m " 
        "the volume is " << volume_nm * vol_factor << "m3" << endl;

在这里,我们正在以纳米nm)为单位对球体进行计算。球体的半径为 10 纳米,因此第一次计算得到的体积为 4188.67 立方纳米。第二次计算将纳米转换为米;因子是从nano比率中确定的(请注意,对于体积,因子是立方的)。您可以定义一个类来进行这样的转换:

    template<typename units> 
    class dist_units 
    { 
        double data; 
        public: 
            dist_units(double d) : data(d) {} 

        template <class other> 
        dist_units(const dist_units<other>& len) : data(len.value() *  
         ratio_divide<units, other>::type::den / 
         ratio_divide<units, other>::type::num) {} 

        double value() const { return data; } 
    };

该类是为特定类型的单位定义的,这将通过ratio模板的实例化来表达。该类有一个构造函数,用于初始化该单位的值,并且有一个构造函数,用于从其他单位转换,它只是将当前单位除以其他类型的单位。这个类可以这样使用:

    dist_units<kilo> earth_diameter_km(12742); 
    cout << earth_diameter_km.value() << "km" << endl; 
    dist_units<ratio<1>> in_meters(earth_diameter_km); 
    cout << in_meters.value()<< "m" << endl; 
    dist_units<ratio<1609344, 1000>> in_miles(earth_diameter_km); 
    cout << in_miles.value()<< "miles" << endl;

第一个变量基于kilo,因此单位是千米。要将其转换为米,第二个变量类型基于ratio<1>,与ratio<1,1>相同。结果是,当放置在in_meters中时,earth_diameter_km中的值将乘以 1000。将其转换为英里就更复杂了。一英里等于 1609.344 米。用于in_miles变量的比率是 1609344/1000 或 1609.344。我们正在用earth_diameter_km初始化变量,所以该值是否不是大了 1000 倍?不,原因在于earth_diameter_km的类型是dist_units<kilo>,因此千米和英里之间的转换将包括这个 1000 倍因子。

复数

复数不仅在数学上有重要意义,而且在工程和科学中也至关重要,因此complex类型是任何类型库的重要组成部分。复数由两部分组成--实部和虚部。正如其名称所示,虚数并非真实存在,不能被视为真实存在。

在数学中,复数通常被表示为二维空间中的坐标。如果一个实数可以被认为是 x 轴上无限多个点中的一个,那么一个虚数可以被认为是 y 轴上无限多个点中的一个。这两者之间唯一的交点是原点,由于零就是零,什么都不是,它可以是零实数或零虚数。复数既有实部又有虚部,因此可以将其视为笛卡尔坐标点。事实上,另一种可视化复数的方法是将其视为极坐标,其中该点被表示为指定长度和指定角度的矢量,指向 x 轴上的位置(正实数轴)。

complex类基于浮点类型,并且有floatdoublelong double的特化版本。该类很简单;它有一个构造函数,用于实数和虚数部分的初始化,并且定义了操作符(成员方法和全局函数)用于赋值、比较、+-/*,作用于实数和虚数部分。

对于复数来说,+这样的操作很简单:只需将实部相加,虚部相加,这两个和就是结果的实部和虚部。然而,乘法和除法就有点复杂了。在乘法中,你会得到一个二次方程:两个实部相乘的总和,两个虚部相乘的总和,第一个实部的值与第二个虚部的值相乘,以及第一个虚部的值与第二个实部的值相乘。复杂之处在于,两个虚数相乘等同于两个等效实数相乘再乘以-1。此外,实数和虚数相乘会得到一个与两个等效实数相乘等大的虚数。

还有一些函数可以对复数执行三角函数操作:sincostansinhcoshtanh;以及基本的数学运算,如logexplog10powsqrt。你还可以调用函数来创建复数并获取有关它们的信息。因此,polar函数将接受两个浮点数,表示矢量长度和角度的极坐标。如果你有一个complex数对象,你可以通过调用abs(获取长度)和arg(获取角度)来获取极坐标。

    complex<double> a(1.0, 1.0); 
    complex<double> b(-0.5, 0.5); 
    complex<double> c = a + b; 
    cout << a << " + " << b << " = " << c << endl; 
    complex<double> d = polar(1.41421, -3.14152 / 4); 
    cout << d << endl;

首先要指出的是,对于complex数,定义了一个ostream插入运算符,因此可以将它们插入到cout流对象中。这段代码的输出如下:

    (1,1) + (-0.5,0.5) = (0.5,1.5)
(1.00002,-0.999979)

第二行显示了仅使用五位小数来表示 2 的平方根和-1/4π的局限性,实际上这个数字是复数(1,-1)

使用标准库。

在这个例子中,我们将开发一个简单的逗号分隔值CSV)文件解析器。我们将遵循的规则如下:

  • 每条记录将占据一行,换行符表示一个新的记录。

  • 记录中的字段由逗号分隔,除非它们在引用的字符串内部。

  • 字符串可以使用单引号(')或双引号(")进行引用,在这种情况下,它们可以包含逗号作为字符串的一部分。

  • 立即重复的引号(''"")是一个字面值,是字符串的一部分而不是字符串的分隔符。

  • 如果一个字符串被引用,那么字符串外部的空格将被忽略。

这是一个非常基本的实现,省略了带引号的字符串可以包含换行符的通常要求。

在这个例子中,大部分操作将使用string对象作为单个字符的容器。

首先在这本书的文件夹中创建一个名为Chapter_08的章节文件夹。在该文件夹中,创建一个名为csv_parser.cpp的文件。由于该应用程序将使用控制台输出和文件输入,因此在文件顶部添加以下行:

    #include <iostream> 
    #include <fstream> 

    using namespace std;

该应用程序还将接受一个命令行参数,即要解析的 CSV 文件,因此在文件底部添加以下代码:

    void usage() 
    { 
        cout << "usage: csv_parser file" << endl; 
        cout << "where file is the path to a csv file" << endl; 
    } 

    int main(int argc, const char* argv[]) 
    { 
        if (argc <= 1) 
        { 
            usage(); 
            return 1; 
        } 
        return 0; 
    }

该应用程序将逐行读取文件到一个string对象的vector中,因此将<vector>添加到包含文件列表中。为了使编码更容易,定义如下内容在usage函数之上:

    using namespace std; 
    using vec_str = vector<string>;

main函数将逐行读取文件,最简单的方法是使用getline函数,因此将<string>头文件添加到包含文件列表中。在main函数的末尾添加以下行:

    ifstream stm; 
    stm.open(argv[1], ios_base::in); 
    if (!stm.is_open()) 
    { 
        usage(); 
        cout << "cannot open " << argv[1] << endl; 
        return 1; 
    } 

    vec_str lines; 
    for (string line; getline(stm, line); ) 
    { 
        if (line.empty()) continue; 
        lines.push_back(move(line)); 
    } 
    stm.close();

前几行使用ifstream类打开文件。如果找不到文件,则打开文件的操作失败,并通过调用is_open进行测试。接下来,声明了一个string对象的vector并填充了从文件中读取的行。getline函数有两个参数:第一个是打开的文件流对象,第二个是包含字符数据的字符串。这个函数返回流对象,它具有bool转换运算符,因此for语句将循环,直到这个流对象指示它无法再读取更多数据为止。当流到达文件末尾时,内部的文件结束标志被设置,这导致bool转换运算符返回false值。

如果getline函数读取了一个空行,那么string将无法解析,因此对此进行了测试,并且这样的空行不会被存储。每个合法的行都被推入vector中,但由于这个string变量在此操作后将不再被使用,我们可以使用移动语义,因此通过调用move函数来明确表示这一点。

这段代码现在可以编译和运行(尽管它不会产生任何输出)。您可以在任何符合先前给定标准的 CSV 文件上使用它,但作为测试文件,我们使用了以下文件:

    George Washington,1789,1797 
    "John Adams, Federalist",1797,1801 
    "Thomas Jefferson, Democratic Republican",1801,1809 
    "James Madison, Democratic Republican",1809,1817 
    "James Monroe, Democratic Republican",1817,1825 
    "John Quincy Adams, Democratic Republican",1825,1829 
    "Andrew Jackson, Democratic",1829,1837 
    "Martin Van Buren, Democratic",1837,1841 
    "William Henry Harrison, Whig",1841,1841 
    "John Tyler, Whig",1841,1841 
    John Tyler,1841,1845

这些是直到 1845 年的美国总统;第一个字符串是总统的名字和他们的从属关系,但当总统没有从属关系时,它被省略了(华盛顿和泰勒)。然后是他们的任期开始和结束年份。

接下来,我们要解析向量中的数据,并根据先前给定的规则(由逗号分隔的字段,但尊重引号)将项目拆分为单独的字段。为此,我们将每一行表示为字段的list,每个字段都是string。在文件顶部附近添加<list>的包含。在进行using声明的地方,添加以下内容:

    using namespace std; 
    using vec_str = vector<string>; 
    using list_str = list<string>;using vec_list = vector<list_str>;

现在,在main函数的底部,添加:

    vec_list parsed; 
    for (string& line : lines) 
    { 
        parsed.push_back(parse_line(line)); 
    }

第一行创建了vectorlist对象,并且for循环遍历每一行,调用一个名为parse_line的函数,该函数解析一个字符串并返回string对象的list。函数的返回值将是一个临时对象,因此是一个 rvalue,这意味着将调用具有移动语义的push_back版本。

在使用函数之上,添加parse_line函数的开始:

    list_str parse_line(const string& line) 
    { 
        list_str data; 
        string::const_iterator it = line.begin(); 

        return data; 
    }

该函数将把字符串视为字符的容器,因此它将使用const_iterator迭代遍历行参数。解析将在do循环中进行,因此添加以下内容:

    list_str data; 
    string::const_iterator it = line.begin(); 
    string item; bool bQuote = false; bool bDQuote = false; do{++it; } while (it != line.end()); data.push_back(move(item)); 
    return data;

布尔变量将在下一刻被解释。do循环递增迭代器,当它达到end值时,循环结束。item变量将保存解析的数据(此时为空),最后一行将值放入list;这样,在函数结束之前,任何未保存的数据都将存储在list中。由于item变量即将被销毁,因此调用move确保将其内容移入list而不是复制。如果没有此调用,将在将项目放入list时调用字符串复制构造函数。

接下来,您需要对数据进行解析。为此,添加一个switch来测试三种情况:逗号(表示字段的结束),引号或双引号(表示引号字符串)。想法是逐个读取每个字段并逐个字符构建其值,使用item变量。

    do 
    { 
        switch (*it) { case ''': break; case '"': break; case ',': break; default: item.push_back(*it); }; 
        ++it; 
    } while (it != line.end());

默认操作很简单:它将字符复制到临时字符串中。如果字符是单引号,我们有两个选项。要么引号在双引号引起的字符串中,这种情况下我们希望引号存储在item中,要么引号是分隔符,这种情况下我们通过设置bQuote值来存储它是开放引号还是闭合引号。对于单引号的情况,添加以下内容:

    case ''': 
    if (bDQuote) item.push_back(*it); else { bQuote = !bQuote; if (bQuote) item.clear(); } 
    break;

这很简单。如果这是在双引号字符串中(bDQuote已设置),那么我们存储引号。如果不是,那么我们翻转bQuote布尔值,以便如果这是第一个引号,我们注册字符串被引用,否则我们注册它是字符串的结尾。如果我们处于引号字符串的开头,我们清除项目变量以忽略前一个逗号(如果有的话)和引号之间的任何空格。但是,此代码没有考虑连续使用两个引号的情况,这意味着引号是字面上的字符串的一部分。更改代码以检查此情况:

    if (bDQuote) item.push_back(*it); 
    else 
    { 
        if ((it + 1) != line.end() && *(it + 1) == ''') { item.push_back(*it); ++it; } else 
        { 
            bQuote = !bQuote; 
            if (bQuote) item.clear(); 
        } 
    }

if语句检查以确保如果我们递增迭代器,我们不在行的末尾(短路将在这种情况下启动,并且不会评估表达式的其余部分)。我们可以测试下一个项目,然后窥视下一个项目,看看它是否是单引号;如果是,则将其添加到item变量中,并递增迭代器,以便在循环中消耗两个引号。

双引号的代码类似,但切换布尔变量并测试双引号:

    case '"': 
    if (bQuote) item.push_back(*it); else { if ((it + 1) != line.end() && *(it + 1) == '"') { item.push_back(*it); ++it; } else { bDQuote = !bDQuote; if (bDQuote) item.clear(); } } 
    break;

最后,我们需要代码来测试逗号。同样,我们有两种情况:要么这是引号中的逗号,在这种情况下,我们需要存储字符,要么这是字段的结尾,在这种情况下,我们需要完成对该字段的解析。代码非常简单:

    case ',': 
    if (bQuote || bDQuote)  item.push_back(*it); else                    data.push_back(move(item)); 
    break;

if语句用于测试我们是否在引号字符串中(在这种情况下,bQuotebDQuote将为 true),如果是,则存储字符。如果这是字段的结尾,我们将string推入list,但我们使用move,以便数据变量被移动,而string对象处于未初始化状态。

这段代码将编译并运行。然而,仍然没有输出,所以在纠正之前,回顾一下您编写的代码。在main函数的末尾,您将拥有一个vector,其中每个项目都有一个代表 CSV 文件中每一行的list对象,而list中的每个项目都是一个字段。您现在已经解析了文件,并可以相应地使用这些数据。为了能够看到数据已被解析,将以下行添加到main函数的底部:

    int count = 0; 
    for (list_str row : parsed) 
    { 
        cout << ++count << "> "; 
        for (string field : row) 
        { 
            cout << field << " "; 
        } 
        cout << endl; 
    }

现在可以编译代码(使用/EHsc开关)并运行应用程序,传递 CSV 文件的名称。

总结

在本章中,您已经看到了 C++标准库中的一些主要类,并深入研究了容器和迭代器类。其中一个这样的容器是string类;这是一个如此重要的类,将在下一章中更深入地介绍。

使用字符串

在某个时候,您的应用程序将需要与人们交流,这意味着使用文本;例如输出文本,以文本形式接收数据,然后将该数据转换为适当的类型。C++标准库有丰富的类集合,用于操作字符串,将字符串和数字之间进行转换,并获取特定语言和文化环境的本地化字符串值。

将字符串类作为容器使用

C++字符串基于basic_string模板类。这个类是一个容器,所以它使用迭代器访问和方法来获取信息,并且具有包含有关其保存的字符类型的信息的模板参数。有不同的特定字符类型的typedef

    typedef basic_string<char,
       char_traits<char>, allocator<char> > string; 
    typedef basic_string<wchar_t,
       char_traits<wchar_t>, allocator<wchar_t> > wstring; 
    typedef basic_string<char16_t,
       char_traits<char16_t>, allocator<char16_t> > u16string; 
    typedef basic_string<char32_t,
       char_traits<char32_t>, allocator<char32_t> > u32string;

string类基于charwstring基于wchar_t宽字符,16stringu32string类分别基于 16 位和 32 位字符。在本章的其余部分,我们将集中讨论string类,但它同样适用于其他类。

比较、复制和访问字符串中的字符将需要针对不同大小的字符编写不同的代码,而特性模板参数提供了实现。对于string,这是char_traits类。例如,当这个类复制字符时,它将把这个动作委托给char_traits类及其copy方法。特性类也被流类使用,因此它们还定义了适合文件流的文件结束值。

字符串本质上是一个零个或多个字符的数组,当需要时分配内存,并在销毁string对象时释放它。在某些方面,它与vector<char>对象非常相似。作为容器,string类通过beginend方法提供迭代器访问:

    string s = "hellon"; 
    copy(s.begin(), s.end(), ostream_iterator<char>(cout));

在这里,调用beginend方法以从string中的项获取迭代器,然后将这些迭代器传递给<algorithm>中的copy函数,以通过ostream_iterator临时对象将每个字符复制到控制台。在这方面,string对象类似于vector,因此我们使用先前定义的s对象:

vector<char> v(s.begin(), s.end()); 
copy(v.begin(), v.end(), ostream_iterator<char>(cout));

使用beginend方法填充vector对象,这些方法在string对象上提供了一系列字符,然后使用copy函数将这些字符以与之前相同的方式打印到控制台。

关于字符串的信息

max_size方法将给出计算机架构上指定字符类型的字符串的最大大小,这可能会非常大。例如,在具有 2GB 内存的 64 位 Windows 计算机上,string对象的max_size将返回 40 亿个字符,而对于wstring对象,该方法将返回 20 亿个字符。这显然比机器上的内存多!其他大小方法返回更有意义的值。length方法返回与size方法相同的值,即字符串中有多少项(字符)。capacity方法指示已分配多少内存用于字符串的字符数。

您可以通过调用其compare方法将string与另一个字符串进行比较。这将返回一个int而不是bool(但请注意,int可以被静默转换为bool),其中返回值为0表示两个字符串相同。如果它们不相同,此方法将返回一个负值,如果参数字符串大于操作数字符串,则返回一个正值。在这方面,大于小于将按字母顺序测试字符串的顺序。此外,还为<<===>=>定义了全局运算符来比较字符串对象。

string对象可以通过c_str方法像 C 字符串一样使用。返回的指针是const的;您应该注意,如果更改了string对象,指针可能会失效,因此不应存储此指针。您不应该使用&str[0]来获取 C++字符串str的 C 字符串指针,因为字符串类使用的内部缓冲区不能保证为NUL终止。c_str方法用于返回一个指针,可以用作 C 字符串,因此是NUL终止的。

如果要从 C++字符串复制数据到 C 缓冲区,可以调用copy方法。您将目标指针和要复制的字符数作为参数传递(以及可选的偏移),该方法将尝试将最多指定数量的字符复制到目标缓冲区:但不包括空终止字符。该方法假定目标缓冲区足够大以容纳复制的字符(您应该采取措施来确保这一点)。如果要传递缓冲区的大小,以便该方法为您执行此检查,请调用_Copy_s方法。

修改字符串

字符串类具有标准的容器访问方法,因此您可以使用at方法和[]运算符通过引用(读写访问)访问单个字符。您可以使用assign方法替换整个字符串,或者使用swap方法交换两个字符串对象的内容。此外,您可以使用insert方法在指定位置插入字符,使用erase方法删除指定的字符,使用clear方法删除所有字符。该类还允许您使用push_backpop_back方法将字符推送到字符串的末尾(并删除最后一个字符)。

    string str = "hello"; 
    cout << str << "n"; // hello 
    str.push_back('!'); 
    cout << str << "n"; // hello! 
    str.erase(0, 1); 
    cout << str << "n"; // ello!

您可以使用append方法或+=运算符在字符串的末尾添加一个或多个字符。

    string str = "hello"; 
    cout << str << "n";  // hello 
    str.append(4, '!'); 
    cout << str << "n";  // hello!!!! 
    str += " there"; 
    cout << str << "n";  // hello!!!! there

<string>库还定义了一个全局的+运算符,用于将两个字符串连接成第三个字符串。

如果要更改字符串中的字符,可以使用[]运算符通过索引访问字符,并使用引用来覆盖字符。您还可以使用replace方法在指定位置用来自 C 字符串或 C++字符串的字符或通过迭代器访问的其他容器替换一个或多个字符。

    string str = "hello"; 
    cout << str << "n";    // hello 
    str.replace(1, 1, "a"); 
    cout << str << "n";    // hallo

最后,您可以将字符串的一部分提取为新字符串。substr方法接受偏移和可选计数。如果省略字符的计数,则子字符串将从指定位置到字符串的末尾。这意味着您可以通过传递偏移为 0 和计数小于字符串大小的方式复制字符串的左侧部分,或者通过仅传递第一个字符的索引来复制字符串的右侧部分。

    string str = "one two three"; 
    string str1 = str.substr(0, 3);  
    cout << str1 << "n";          // one 
    string str2 = str.substr(8); 
    cout << str2 << "n";          // three

在此代码中,第一个示例将前三个字符复制到一个新字符串中。在第二个示例中,复制从第八个字符开始,一直到末尾。

搜索字符串

find方法可以使用字符、C 字符串或 C++字符串进行传递,并且您可以提供一个初始搜索位置来开始搜索。find方法返回搜索文本的位置(而不是迭代器),或者如果找不到文本,则返回npos值。偏移参数和find方法的成功返回值使您能够重复解析字符串以查找特定项。find方法在正向方向搜索指定的文本,还有一个rfind方法可以在反向方向执行搜索。

请注意,rfind并不是find方法的完全相反。find方法在字符串中向前移动搜索点,并在每个点上将搜索字符串与搜索点之后的字符进行比较(所以首先是搜索文本的第一个字符,然后是第二个字符,依此类推)。rfind方法向后移动搜索点,但比较仍然是向前进行的。所以,假设rfind方法没有给出偏移量,第一次比较将在字符串末尾与搜索文本大小的偏移量处进行。然后,通过将搜索文本中的第一个字符与搜索字符串中搜索点后的字符进行比较,如果成功,则将搜索文本中的第二个字符与搜索点后的字符进行比较。因此,比较是沿着搜索点移动的方向相反进行的。

这变得重要,因为如果你想使用find方法的返回值作为偏移量来解析一个字符串,每次搜索后你应该将搜索偏移量向前移动,而对于rfind,你应该将其向后移动。

例如,要在以下字符串中搜索the的所有位置,你可以调用:

    string str = "012the678the234the890"; 
    string::size_type pos = 0; 
    while(true) 
    { 
        pos++; 
        pos = str.find("the",pos); 
        if (pos == string::npos) break; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // 3 the678the234the890 
    // 9 the234the890 
    // 15 the890

这将在字符位置 3、9 和 15 找到搜索文本。要向后搜索字符串,可以调用:

    string str = "012the678the234the890"; 
    string::size_type pos = string::npos; 
    while(true) 
    { 
        pos--; pos = str.rfind("the",pos); 
        if (pos == string::npos) break; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // 15 the890 
    // 9 the234the890 
    // 3 the678the234the890

突出显示的代码显示了应该进行的更改,告诉你需要从末尾开始搜索并使用rfind方法。当你有一个成功的结果时,你需要在下一次搜索之前减少位置。与find方法一样,如果找不到搜索文本,rfind方法会返回npos

有四种方法允许你搜索多个单个字符中的一个。例如:

    string str = "012the678the234the890"; 
    string::size_type pos = str.find_first_of("eh"); 
    if (pos != string::npos) 
    { 
        cout << "found " << str[pos] << " at position "; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // found h at position 4 he678the234the890

搜索字符串是ehfind_first_of会在字符串中找到eh字符时返回。在这个例子中,字符h首先在位置 4 被找到。你可以提供一个偏移参数来开始搜索,所以你可以使用find_first_of的返回值来解析字符串。find_last_of方法类似,但它以相反的方向搜索搜索文本中的字符。

还有两种搜索方法,它们将查找搜索文本中不是提供的字符:find_first_not_offind_last_not_of。例如:

    string str = "012the678the234the890"; 
    string::size_type pos = str.find_first_not_of("0123456789"); 
    cout << "found " << str[pos] << " at position "; 
    cout << pos << " " << str.substr(pos) << "n"; 
    // found t at position 3 the678the234the890

这段代码查找的是非数字字符,所以它在位置 3(第四个字符)找到了t

没有库函数可以从string中修剪空白字符,但你可以通过使用 find 函数找到非空白字符,然后将其作为substr方法的适当索引来修剪字符串的左侧和右侧空格。

    string str = "  hello  "; 
    cout << "|" << str << "|n";  // |  hello  | 
    string str1 = str.substr(str.find_first_not_of(" trn")); 
    cout << "|" << str1 << "|n"; // |hello  | 
    string str2 = str.substr(0, str.find_last_not_of(" trn") + 1); 
    cout << "|" << str2 << "|n"; // |  hello|

在上面的代码中,创建了两个新的字符串:一个左侧修剪空格,另一个右侧修剪空格。第一个向前搜索第一个非空白字符,并将其用作子字符串的起始索引(因为没有提供计数,所以将复制所有剩余的字符串)。在第二种情况下,字符串是反向搜索非空白字符,但返回的位置将是hello的最后一个字符;因为我们需要从第一个字符开始的子字符串,所以我们增加这个索引以获得要复制的字符数。

国际化

<locale>头文件包含了本地化时间、日期和货币格式的类,还提供了本地化的字符串比较和排序规则。

C 运行时库还具有全局函数来执行本地化。但是,在以下讨论中,重要的是区分 C 函数和 C 区域设置。C 区域设置是 C 和 C++程序中使用的默认区域设置,包括本地化规则,可以用国家或文化的区域设置替换。C 运行时库提供了更改区域设置的函数,C++标准库也提供了这些函数。

由于 C++标准库提供了本地化类,这意味着可以创建多个表示区域设置的对象。区域设置对象可以在函数中创建,并且只能在那里使用,或者可以全局应用于线程,并且仅由在该线程上运行的代码使用。这与 C 本地化函数相反,其中更改区域设置是全局的,因此所有代码(以及所有执行线程)都会受到影响。

locale 类的实例可以通过类构造函数或类的静态成员创建。C++流类将使用区域设置(稍后解释),如果要更改区域设置,则调用流对象的 imbue 方法。在某些情况下,您可能需要直接访问其中一个规则,并且可以通过区域设置对象访问它们。

使用 facet

国际化规则称为facet。区域设置对象是 facet 的容器,可以使用 has_facet 函数测试区域设置是否具有特定 facet;如果有,可以通过调用 use_facet 函数获得 facet 的 const 引用。以下表格总结了七个类别的七种类别的六种 facet 类型。facet 类是 locale::facet 嵌套类的子类。

Facet 类型描述
codecvtctype在不同编码方案之间进行转换,并用于对字符进行分类并将其转换为大写或小写
collate控制字符串中字符的排序和分组,包括比较和哈希字符串
messages从目录中检索本地化消息
money将表示货币的数字转换为字符串,反之亦然
num将数字转换为字符串,反之亦然
时间将数字形式的时间和日期转换为字符串,反之亦然

facet 类用于将数据转换为字符串,因此它们都具有用于字符类型的模板参数。moneynumtime facet 由三个类表示。具有 _get 后缀的类处理解析字符串,而具有 _put 后缀的类处理格式化为字符串。对于 moneynum facet,有一个包含标点规则和符号的 punct 后缀的类。

由于 _get facet 用于将字符序列转换为数值类型,因此类具有模板参数,您可以使用该参数指示 get 方法将用于表示字符范围的输入迭代器类型。同样,_put facet 类具有模板参数,您可以使用该参数提供 put 方法将转换后的字符串写入的输出迭代器类型。对于两种迭代器类型都提供了默认类型。

messages facet 用于与 POSIX 代码兼容。该类旨在允许您为应用程序提供本地化字符串。其想法是,用户界面中的字符串被索引,并且在运行时,您可以通过 messages facet 使用索引访问本地化字符串。但是,Windows 应用程序通常使用使用消息编译器编译的消息资源文件。也许正因为这个原因,标准库提供的 messages facet 并不执行任何操作,但是基础设施已经存在,您可以派生自己的 messages facet 类。

has_facetuse_facet函数是为你想要的特定类型的 facet 进行模板化的。所有 facet 类都是locale::facet类的子类,但通过这个模板参数,编译器将实例化一个返回你请求的特定类型的函数。所以,例如,如果你想要为法语区域设置格式化时间和日期字符串,你可以调用这段代码:

    locale loc("french"); 
    const time_put<char>& fac = use_facet<time_put<char>>(loc);

在这里,french字符串标识了区域设置,这是 C 运行时库setlocale函数使用的语言字符串。第二行获取了用于将数字时间转换为字符串的 facet,因此函数模板参数是time_put<char>。这个类有一个叫做put的方法,你可以调用它来执行转换:

    time_t t = time(nullptr); 
    tm *td = gmtime(&t); 
    ostreambuf_iterator<char> it(cout); 
    fac.put(it, cout, ' ', td, 'x', '#'); 
    cout << "n";

time函数(通过<ctime>)返回一个带有当前时间和日期的整数,然后使用gmtime函数将其转换为tm结构。tm结构包含年、月、日、小时、分钟和秒的各个成员。gmtime函数返回一个在函数中静态分配的结构的地址,因此你不必删除它占用的内存。

facet 将tm结构中的数据格式化为一个字符串,通过作为第一个参数传递的输出迭代器。在这种情况下,输出流迭代器是从cout对象构造的,因此 facet 将把格式化流写入控制台(第二个参数没有被使用,但因为它是一个引用,你必须传递一些东西,所以也在那里使用了cout对象)。第三个参数是分隔符字符(同样,这也没有被使用)。第五和(可选的)第六个参数指示你需要的格式化。这些是与 C 运行时库函数strftime中使用的相同的格式化字符,作为两个单个字符,而不是 C 函数使用的格式字符串。在这个例子中,x用于获取日期,#用作字符串的长版本的修饰符。

代码将给出以下输出:

    samedi 28 janvier 2017

注意单词没有大写,也没有标点符号,还要注意顺序:星期几名称,日期,月份,然后年份。

如果locale对象构造函数参数被更改为german,那么输出将是:

    Samstag, 28\. January 2017

项目的顺序与法语中相同,但单词是大写的,使用了标点符号。如果你使用turkish,那么结果是:

    28 Ocak 2017 Cumartesi

在这种情况下,星期几在字符串的末尾。

两个国家因共同语言而分裂,将给出两个不同的字符串,以下是americanenglish-uk的结果:

    Saturday, January 28, 2017
28 January 2017

这里以时间作为示例,因为没有流,所以对于tm结构使用插入运算符是一个不寻常的情况。对于其他类型,有插入运算符将它们放入流中,因此流可以使用区域设置来国际化它显示的类型。例如,你可以将一个double插入到cout对象中,该值将被打印到控制台上。默认区域设置,美国英语,使用句点将整数部分与小数部分分开,但在其他文化中使用逗号。

imbue函数将改变本地化,直到随后调用该方法为止:

    cout.imbue(locale("american")); 
    cout << 1.1 << "n"; 
    cout.imbue(locale("french")); 
    cout << 1.1 << "n"; 
    cout.imbue(locale::classic());

在这里,流对象被本地化为美国英语,然后浮点数1.1被打印到控制台上。接下来,本地化被更改为法语,这时控制台将显示1,1。在法语中,小数点是逗号。最后一行通过传递从static classic方法返回的区域设置了流对象。这返回了所谓的C 区域,它是 C 和 C++中的默认区域,是美国英语。

static方法global可以用来设置每个流对象默认使用的区域设置。当从流类创建对象时,它调用locale::global方法获取默认区域设置。流会克隆这个对象,以便它有自己独立于通过调用global方法设置的任何本地设置的副本。请注意,cincout流对象在调用main函数之前创建,这些对象将使用默认的 C 区域设置,直到您使用其他区域设置。然而,重要的是要指出,一旦流被创建,global方法对流没有影响,imbue是改变流使用的区域设置的唯一方法。

global方法还将调用 C setlocale函数来改变 C 运行时库函数使用的区域设置。这很重要,因为一些 C++函数(例如to_stringstod,如下文所述)将使用 C 运行时库函数来转换值。然而,C 运行时库对 C++标准库一无所知,因此调用 C setlocale函数来更改默认区域设置不会影响随后创建的流对象。

值得指出的是,basic_string类使用模板参数指示的字符特征类比较字符串。string类使用char_traits类,其compare方法的版本直接比较两个字符串中对应的字符。这种比较不考虑比较字符的文化规则。如果您想进行使用文化规则的比较,可以通过collate facet 来实现:

    int compare( 
       const string& lhs, const string& rhs, const locale& loc) 
    { 
        const collate<char>& fac = use_facet<collate<char>>(loc); 
        return fac.compare( 
            &lhs[0], &lhs[0] + lhs.size(), &rhs[0], &rhs[0] + rhs.size()); 
    }

字符串和数字

标准库包含了各种函数和类,用于在 C++字符串和数值之间进行转换。

将字符串转换为数字

C++标准库包含了名为stodstoi的函数,它们将 C++ string对象转换为数值(stod转换为doublestoi转换为integer)。例如:

    double d = stod("10.5"); 
    d *= 4; 
    cout << d << "n"; // 42

这将使用值10.5初始化浮点变量d,然后在计算中使用该值,并将结果打印到控制台。输入字符串可能包含无法转换的字符。如果是这种情况,那么字符串的解析将在那一点结束。您可以提供一个指向size_t变量的指针,该变量将被初始化为无法转换的第一个字符的位置:

    string str = "49.5 red balloons"; 
    size_t idx = 0; 
    double d = stod(str, &idx); 
    d *= 2; 
    string rest = str.substr(idx); 
    cout << d << rest << "n"; // 99 red balloons

在前面的代码中,idx变量将被初始化为4的值,表示5r之间的空格是第一个无法转换为double的字符。

将数字转换为字符串

<string>库提供了各种重载的to_string函数,用于将整数类型和浮点类型转换为string对象。这个函数不允许你提供任何格式化细节,所以对于整数,你不能指示字符串表示的基数(例如,十六进制),对于浮点数转换,你无法控制选项,比如有效数字的数量。to_string函数是一个简单的函数,功能有限。更好的选择是使用流类,如下一节所述。

使用流类

您可以使用cout对象(ostream类的实例)将浮点数和整数打印到控制台,也可以使用ofstream的实例将它们打印到文件中。这两个类都将使用成员方法和操作器将数字转换为字符串,并影响输出字符串的格式。同样,cin对象(istream类的实例)和ifstream类可以从格式化流中读取数据。

操纵器是接受流对象引用并返回该引用的函数。标准库有各种全局插入操作符,其参数是流对象的引用和函数指针。适当的插入操作符将调用带有流对象作为参数的函数指针。这意味着操纵器将可以访问并操纵它被插入的流。对于输入流,还有具有函数参数的提取操作符,该参数将调用带有流对象的函数。

C++流的架构意味着在你的代码中调用流接口和获取数据的底层基础设施之间有一个缓冲区。C++标准库提供了将字符串对象作为缓冲区的流类。对于输出流,你可以在项目插入到流中后访问字符串,这意味着字符串将包含根据这些插入操作符格式化的项目。同样,你可以提供一个包含格式化数据的字符串作为输入流的缓冲区,当你使用提取操作符从流中提取数据时,实际上是解析字符串并将字符串的部分转换为数字。

此外,流类有一个locale对象,流对象将调用此区域的转换部分,将一个编码的字符序列转换为另一个编码。

输出浮点数

<ios>库有操纵器可以改变流如何处理数字。默认情况下,输出流将以十进制格式打印浮点数,范围在0.001到“100000”之间,对于超出此范围的数字,它将使用带有尾数和指数的科学格式。这种混合格式是defaultfloat操纵器的默认行为。如果你总是想使用科学计数法,那么你应该在输出流中插入scientific操纵器。

如果你想仅使用十进制格式显示浮点数(即小数点左侧的整数部分和右侧的小数部分),那么可以通过使用fixed操纵器修改输出流。可以通过调用precision方法来改变小数位数:

    double d = 123456789.987654321; 
    cout << d << "n"; 
    cout << fixed; 
    cout << d << "n"; 
    cout.precision(9); 
    cout << d << "n"; 
    cout << scientific; 
    cout << d << "n";

上述代码的输出是:

 1.23457e+08
 123456789.987654
 123456789.987654328
 1.234567900e+08

第一行显示科学计数法用于大数。第二行显示了fixed的默认行为,即给出小数点后 6 位小数。通过调用precision方法将其更改为给出 9 位小数(可以通过在流中插入“iomanip”库中的setprecision操纵器来实现相同的效果)。最后,通过调用precision方法将格式切换为科学格式,小数点后有 9 位数字。默认情况下,指数由小写的e表示。如果你愿意,可以使用uppercase操纵器(和nouppercase)将其改为大写。请注意,分数部分存储的方式意味着在固定格式中,小数点后有 9 位数字,我们看到第九位数字是8,而不是预期的1

你还可以指定正数是否显示+符号;showpos操纵器将显示该符号,但默认的noshowpos操纵器将不显示该符号。showpoint操纵器将确保即使浮点数是整数,也会显示小数点。默认值是noshowpoint,这意味着如果没有小数部分,就不会显示小数点。

setw操纵器(在“iomanip”头文件中定义)可用于整数和浮点数。实际上,这个操纵器定义了在控制台上打印时下一个(仅下一个)放入流中的项目所占用的最小宽度空间:

    double d = 12.345678; 
    cout << fixed; 
    cout << setfill('#'); 
    cout << setw(15) << d << "n";

为了说明setw操纵器的效果,此代码调用setfill操纵器,该操纵器指示应打印井号(#)而不是空格。代码的其余部分表示应以固定格式(默认情况下为 6 位小数)在 15 个字符宽的空间中打印数字。结果是:

    ######12.345678

如果数字为负数(或使用showpos),则默认情况下符号将与数字一起显示;如果使用internal操纵器(在<ios>中定义),则符号将左对齐在为数字设置的空间中:

    double d = 12.345678; 
    cout << fixed; 
    cout << showpos << internal; 
    cout << setfill('#'); 
    cout << setw(15) << d << "n";

上述代码的结果如下:

    +#####12.345678

请注意,空格右侧的+符号由井号表示。

setw操纵器通常用于允许您以格式化的列输出数据表:

    vector<pair<string, double>> table 
    { { "one",0 },{ "two",0 },{ "three",0 },{ "four",0 } }; 

    double d = 0.1; 
    for (pair<string,double>& p : table) 
    { 
        p.second = d / 17.0; 
        d += 0.1; 
    } 

    cout << fixed << setprecision(6); 

    for (pair<string, double> p : table) 
    { 
        cout << setw(6)  << p.first << setw(10) << p.second << "n"; 
    }

这将使用字符串和数字填充vector对。vector用字符串值和零初始化,然后在for循环中更改浮点数(这里实际计算无关紧要;重点是创建一些具有多个小数位的数字)。数据以两列打印出来,数字以 6 位小数打印。这意味着,包括前导零和小数点,每个数字将占用 8 个空间。文本列被指定为 6 个字符宽,数字列被指定为 10 个字符宽。默认情况下,当您指定列宽时,输出将右对齐,这意味着每个数字前面有两个空格,文本根据字符串的长度进行填充。输出如下:

 one  0.005882
 two  0.011765
 three  0.017647
 four  0.023529

如果要使列中的项目左对齐,则可以使用left操纵器。这将影响所有列,直到使用right操纵器将对齐方式更改为右对齐为止:

    cout << fixed << setprecision(6) << left;

这将输出:

 one   0.005882
 two   0.011765
 three 0.017647
 four  0.023529

如果要为两列设置不同的对齐方式,则需要在打印值之前设置对齐方式。例如,要左对齐文本并右对齐数字,请使用以下代码:

    for (pair<string, double> p : table) 
    { 
        cout << setw(6) << left << p.first  
            << setw(10) << right << p.second << "n"; 
    }

上述代码的结果如下:

 one     0.005882
 two     0.011765
 three   0.017647
 four    0.023529

输出整数

整数也可以使用setwsetfill方法以列的形式打印。您可以插入操纵器以使用八进制(oct),十进制(dec)和十六进制(hex)打印整数。(您还可以使用setbase操纵器并传递要使用的基数,但允许的唯一值是 8、10 和 16。)可以使用showbasenoshowbase操纵器打印带有指示基数的数字(八进制前缀为0或十六进制前缀为0x)或不带。如果使用hex,则大于9的数字是字母af,默认情况下这些是小写的。如果您希望这些为大写,则可以使用uppercase操纵器(并使用nouppercase操纵器转换为小写)。

输出时间和金钱

<iomanip>中的put_time函数传递了一个初始化为时间和日期的tm结构和一个格式字符串。该函数返回_Timeobj类的一个实例。顾名思义,您实际上不应该创建此类的变量;相反,应该使用该函数将具有特定格式的时间/日期插入流中。有一个插入运算符将打印_Timeobj对象。该函数的使用方式如下:

    time_t t = time(nullptr); 
    tm *pt = localtime(&t); 
    cout << put_time(pt, "time = %X date = %x") << "n";

这将输出:

    time = 20:08:04 date = 01/02/17

该函数将使用流中的区域设置,因此如果将区域设置为流中,然后调用put_time,则将使用区域设置的时间/日期格式化规则和格式字符串。格式字符串使用strftime的格式标记:

    time_t t = time(nullptr); 
    tm *pt = localtime(&t); 
    cout << put_time(pt, "month = %B day = %A") << "n"; 
    cout.imbue(locale("french")); 
    cout << put_time(pt, "month = %B day = %A") << "n";

上述代码的输出如下:

 month = March day = Thursday
 month = mars day = jeudi

类似地,put_money函数返回一个_Monobj对象。同样,这只是一个包含您传递给此函数的参数的容器,您不应该使用此类的实例。相反,您应该将此函数插入到输出流中。实际工作发生在插入运算符中,该运算符获取当前区域设置上的货币 facet,使用它来将数字格式化为适当数量的小数位,并确定小数点字符;如果使用了千位分隔符,则在适当位置插入它之前。

    Cout << showbase; 
    cout.imbue(locale("German")); 
    cout << "German" << "n"; 
    cout << put_money(109900, false) << "n"; 
    cout << put_money("1099", true) << "n"; 
    cout.imbue(locale("American")); 
    cout << "American" << "n"; 
    cout << put_money(109900, false) << "n"; 
    cout << put_money("1099", true) << "n";

前面代码的输出是:

 German
 1.099,00 euros
 EUR10,99
 American
 $1,099.00
 USD10.99

您可以使用double或字符串提供欧分或分的数字,并且put_money函数将使用适当的小数点(德国为,,美国为.)和适当的千位分隔符(德国为.,美国为,)格式化欧元或美元的数字。将showbase操作器插入到输出流中意味着put_money函数将显示货币符号,否则只会显示格式化的数字。put_money函数的第二个参数指定使用货币字符(false)还是国际符号(true)。

使用流将数字转换为字符串

流缓冲区类负责从适当的源(文件、控制台等)获取字符并写入字符,并且从<streambuf>中的抽象类basic_streambuf派生。此基类定义了两个虚拟方法,overflowunderflow,派生类重写这些方法以从与派生类关联的设备中写入和读取字符(分别)。流缓冲区类执行将项目放入流中的基本操作,由于缓冲区处理字符,因此该类使用字符类型和字符特征的参数进行模板化。

顾名思义,如果使用basic_stringbuf,则流缓冲区将是一个字符串,因此读取字符的源和写入字符的目的地是该字符串。如果使用此类为流对象提供缓冲区,这意味着您可以使用为流编写的插入或提取运算符,将格式化的数据写入或从字符串中读取。basic_stringbuf缓冲区是可扩展的,因此当您在流中插入项目时,缓冲区将适当地扩展。有typedef,其中缓冲区是stringstringbuf)或wstringwstringbuf)。

例如,假设您有一个已定义的类,并且还定义了插入运算符,以便您可以使用cout对象将值打印到控制台:

    struct point 
    { 
        double x = 0.0, y = 0.0; 
        point(){} 
        point(double _x, double _y) : x(_x), y(_y) {} 
    }; 

    ostream& operator<<(ostream& out, const point& p) 
    { 
        out << "(" << p.x << "," << p.y << ")"; 
        return out; 
    }

使用cout对象很简单--考虑以下代码片段:

    point p(10.0, -5.0); 
    cout << p << "n";         // (10,-5)

您可以使用stringbuf将格式化的输出定向到字符串而不是控制台:

    stringbuf buffer;  
    ostream out(&buffer); 
    out << p; 
    string str = buffer.str(); // contains (10,-5)

由于流对象处理格式,这意味着您可以插入任何数据类型,只要有插入运算符,并且可以使用任何ostream格式化方法和任何操作器。所有这些方法和操作器的格式化输出将插入到缓冲区中的字符串对象中。

另一个选项是使用<sstream>中的basic_ostringstream类。该类是基于用作缓冲区的字符串的字符类型的模板(因此string版本是ostringstream)。它派生自ostream类,因此您可以在任何使用ostream对象的地方使用实例。格式化的结果可以通过str方法访问:

    ostringstream os; 
    os << hex; 
    os << 42; 
    cout << "The value is: " << os.str() << "n";

此代码以十六进制(2a)获取42的值;这是通过在流中插入hex操作器,然后插入整数来实现的。通过调用str方法获取格式化的字符串。

使用流从字符串中读取数字

cin对象是istream类的一个实例(在<istream>库中),可以从控制台输入字符并将其转换为你指定的数字形式。ifstream类(在<ifstream>库中)也允许你从文件中输入字符并将其转换为数字形式。与输出流一样,你可以使用流类与字符串缓冲区,以便你可以从字符串对象转换为数字值。

basic_istringstream类(在<sstream>库中)是从basic_istream类派生的,所以你可以创建流对象,并从这些对象中提取项目(数字和字符串)。该类在字符串对象上提供了这个流接口(typedef关键字istringstream基于stringwistringstream基于wstring)。当你构造这个类的对象时,你用一个包含数字的string初始化对象,然后你使用>>操作符从基本内置类型中提取对象,就像你使用cin从控制台提取这些项目一样。

需要重申的是,提取操作符将空白字符视为流中项目之间的分隔符,因此它们将忽略所有前导空白字符,读取非空白字符直到下一个空白字符,并尝试将这个子字符串转换为适当的类型,如下所示:

    istringstream ss("-1.0e-6"); 
    double d; 
    ss >> d;

这将用值-1e-6初始化变量d。与cin一样,你必须知道流中项目的格式;所以,如果在前面的例子中,你尝试从字符串中提取一个double而不是一个整数,当遇到小数点时,对象将停止提取字符。如果字符串的一部分没有被转换,你可以将剩下的部分提取到一个字符串对象中:

    istringstream ss("-1.0e-6"); 
    int i; 
    ss >> i; 
    string str; 
    ss >> str; 
    cout << "extracted " << i << " remainder " << str << "n";

这将在控制台上打印以下内容:

    extracted -1 remainder .0e-6

如果字符串中有多个数字,你可以通过多次调用>>操作符来提取这些数字。流还支持一些操作器。例如,如果字符串中的数字是以hex格式,你可以使用hex操作器通知流,如下所示:

    istringstream ss("0xff"); 
    int i; 
    ss >> hex; 
    ss >> i;

这表示字符串中的数字是十六进制格式,变量i将被初始化为 255。如果字符串包含非数字值,那么流对象仍然会尝试将字符串转换为适当的格式。在下面的片段中,你可以通过调用fail函数测试这样的提取是否失败:

    istringstream ss("Paul was born in 1942"); 
    int year; 
    ss >> year; 
    if (ss.fail()) cout << "failed to read number" << "n";

如果你知道字符串包含文本,你可以将它提取到字符串对象中,但请记住空白字符被视为分隔符:

    istringstream ss("Paul was born in 1942"); 
    string str; 
    ss >> str >> str >> str >> str; 
    int year; 
    ss >> year;

在这里,数字之前有四个单词,所以代码会读取一个string四次。如果你不知道数字在字符串中的位置,但你知道字符串中有一个数字,你可以移动内部缓冲指针,直到它指向一个数字:

    istringstream ss("Paul was born in 1942"); 
    string str;    
    while (ss.eof() && !(isdigit(ss.peek()))) ss.get(); 
    int year; 
    ss >> year; 
    if (!ss.fail()) cout << "the year was " << year << "n";

peek方法返回当前位置的字符,但不移动缓冲指针。这段代码检查这个字符是否是一个数字,如果不是,就通过调用get方法移动内部缓冲指针。(这段代码测试eof方法以确保在缓冲结束后没有尝试读取字符。)如果你知道数字从哪里开始,你可以调用seekg方法将内部缓冲指针移动到指定位置。

<istream>库有一个叫做ws的操作器,可以从流中移除空白字符。回想一下我们之前说过,没有函数可以从字符串中移除空白字符。这是因为ws操作器从中移除空白字符,而不是从字符串中移除,但是由于你可以使用字符串作为流的缓冲,这意味着你可以间接地使用这个函数从字符串中移除空白字符:

    string str = "  hello  "; 
    cout << "|" << str1 << "|n"; // |  hello  | 
    istringstream ss(str); 
    ss >> ws; 
    string str1; 
    ss >> str1; 
    ut << "|" << str1 << "|n";   // |hello|

ws函数本质上是遍历输入流中的项目,并在遇到非空白字符时返回。如果流是文件或控制台流,则ws函数将从这些流中读取字符;在这种情况下,缓冲区由已分配的字符串提供,因此它会跳过字符串开头的空格。请注意,流类将后续空格视为流中值之间的分隔符,因此在这个例子中,流将从缓冲区中读取字符,直到遇到空格,并且本质上会左-**和右-修剪字符串。但是,这不一定是您想要的。如果您有一个由空格填充的字符串,这段代码只会提供第一个单词。

<iomanip>库中的get_moneyget_time操作器允许您使用货币和时间区域设置从字符串中提取货币和时间:

    tm indpday = {}; 
    string str = "4/7/17"; 
    istringstream ss(str); 
    ss.imbue(locale("french")); 
    ss >> get_time(&indpday, "%x"); 
    if (!ss.fail())  
    { 
       cout.imbue(locale("american")); 
       cout << put_time(&indpday, "%x") << "n";  
    }

在上述代码中,流首先用法国格式(日/月/年)的日期初始化,然后使用区域设置的标准日期表示提取日期。日期被解析为tm结构,然后使用put_time在美国区域设置中以标准日期表示打印出来。结果是:

    7/4/2017

使用正则表达式

正则表达式是文本模式,可以被正则表达式解析器用来搜索匹配模式的文本字符串,并在必要时用其他文本替换匹配的项目。

定义正则表达式

正则表达式regex)由定义模式的字符组成。表达式包含对解析器有意义的特殊符号,如果您想在表达式中的搜索模式中使用这些符号,可以用反斜杠(\)对它们进行转义。您的代码通常会将表达式作为string对象传递给regex类的实例作为构造函数参数。然后将该对象传递给<regex>中的函数,这些函数将使用表达式来解析文本以匹配模式的序列。

下表总结了regex类可以匹配的一些模式。

模式解释示例
literals匹配确切的字符li 匹配 flip lip plier
[group]匹配组中的单个字符[at] 匹配 cat, cat, top, pear
[^group]匹配不在组中的单个字符[^at] 匹配 cat, top, top, pear, pear, pear
[first-last]匹配范围firstlast中的任何字符[0-9] 匹配数字 102, 102, 102
{n}元素精确匹配 n 次91{2} 匹配 911
{n,}元素匹配 n 次或更多次wel{1,} 匹配 wellwelcome
{n,m}元素匹配 n 到 m 次9{2,4} 匹配 99, 999, 9999, 99999 但不匹配 9
.通配符,除了n之外的任何字符a.e 匹配 ateare
*元素匹配零次或多次d*.d 匹配 .1, 0.1, 10.1 但不匹配 10
+元素匹配一次或多次d*.d 匹配 0.1, 10.1 但不匹配 10 或 .1
?元素匹配零次或一次tr?ap 匹配 traptap
|匹配由&#124;分隔的元素中的任何一个th(e&#124;is&#124;at) 匹配 the, this, that
[[:class:]]匹配字符类[[:upper:]] 匹配大写字符:I am Richard
n匹配换行符
s匹配任何单个空格
d匹配任何单个数字d[0-9]
w匹配单词中的字符(大写和小写字符)
b匹配字母数字字符和非字母数字字符之间的边界d{2}b 匹配 999 和 9999 bd{2} 匹配 999 和 9999
$行的结尾s$匹配一行末尾的单个空格
行的开头^d匹配如果一行以数字开头

你可以使用正则表达式来定义一个要匹配的模式--Visual C++编辑器允许你在搜索对话框中这样做(这是一个很好的测试平台来开发你的表达式)。

定义一个匹配模式要比定义一个匹配的模式容易得多。例如,表达式w+b<w+>将匹配字符串"vector<int>",因为它有一个或多个单词字符,后面跟着一个非单词字符(<),然后是一个或多个单词字符,最后是>。这个模式不会匹配字符串"#include <regex>",因为include后面有一个空格,b表示字母数字字符和非字母数字字符之间有一个边界。

表格中的th(e|is|at)示例表明,当你想提供替代方案时,你可以使用括号来分组模式。然而,括号还有另一个用途--它们允许你捕获组。因此,如果你想执行替换操作,你可以搜索一个模式作为一个组,然后稍后引用该组作为一个命名的子组(例如,搜索(Joe),这样你就可以用Tom替换Joe)。你还可以在表达式中引用由括号指定的子表达式(称为反向引用):

    ([A-Za-z]+) +1

这个表达式说:搜索包含一个或多个字符在 a 到 z 和 A 到 Z 范围内的单词;这个单词叫 1,所以找到它出现两次并且中间有一个空格

标准库类

要进行匹配或替换,你必须创建一个正则表达式对象。这是一个basic_regex类的对象,它有字符类型和正则表达式特征类的模板参数。这个类有两个typedefregex表示charwregex表示宽字符,它们的特征由regex_traitswregex_traits类描述。

特征类确定了正则表达式类如何解析表达式。例如,回想一下之前的文本,你可以用w表示一个单词,d表示一个数字,s表示一个空格。[[::]]语法允许你使用更具描述性的名称来表示字符类:alnumdigitlower等等。由于这些是依赖于字符集的文本序列,特征类将有适当的代码来测试表达式是否使用了支持的字符类。

适当的正则表达式类将解析表达式,以便<regex>库中的函数使用表达式来识别文本中的模式:

    regex rx("([A-Za-z]+) +1");

这搜索重复的单词使用了反向引用。请注意,正则表达式使用1表示反向引用,但在字符串中反斜杠必须转义(\)。如果你使用字符类如sd,那么你将需要做很多转义。相反,你可以使用原始字符串(R"()"),但要记住引号内的第一组括号是原始字符串的语法的一部分,不是正则表达式组的一部分:

    regex rx(R"(([A-Za-z]+) +1)");

哪种更易读完全取决于你;两者都在双引号内引入了额外的字符,这可能会让人快速浏览时对正则表达式匹配的内容感到困惑。

请记住,正则表达式本质上是一个程序,因此regex解析器将确定该表达式是否有效,如果无效,对象、构造函数将抛出regex_error类型的异常。异常处理将在下一章中解释,但重要的是要指出,如果异常没有被捕获,将导致应用在运行时中止。异常的what方法将返回错误的基本描述,code方法将返回regex_constants命名空间中error_type枚举中的常量之一。没有指示错误发生在表达式的哪个位置。您应该在外部工具(例如 Visual C++搜索)中彻底测试您的表达式。

构造函数可以使用字符串(C 或 C++)或一对迭代器来调用字符串(或其他容器)中一系列字符的范围,或者可以传递一个初始化列表,其中列表中的每个项都是一个字符。正则表达式有各种不同的语言风格;basic_regex类的默认风格是ECMAScript。如果您想要不同的语言(基本 POSIX、扩展 POSIX、awk、grep 或 egrep),可以传递regex_constants命名空间中syntax_option_type枚举中定义的常量之一(也可以作为basic_regex类中定义的常量的副本)作为构造函数参数。

您只能指定一种语言风格,但您可以将其与其他syntax_option_type常量结合使用:icase指定不区分大小写,collate使用匹配中的区域设置,nosubs表示您不想捕获组,optimize优化匹配。

该类使用getloc方法获取解析器使用的区域设置,并使用imbue重置区域设置。如果您imbue一个区域设置,那么在使用assign方法重置之前,您将无法使用regex对象进行任何匹配。这意味着有两种使用regex对象的方法。如果要使用当前区域设置,则将正则表达式传递给构造函数:如果要使用不同的区域设置,则使用默认构造函数创建一个空的regex对象,然后使用imbue调用区域设置,并使用assign方法传递正则表达式。一旦解析了正则表达式,就可以调用mark_count方法获取表达式中捕获组的数量(假设您没有使用nosubs)。

匹配表达式

一旦构造了一个regex对象,您可以将其传递给<regex>库中的方法,以在字符串中搜索模式。regex_match函数传入一个字符串(C 或 C++)或容器中一系列字符的迭代器以及一个构造的regex对象。在其最简单的形式中,该函数只有在有精确匹配时才会返回true,也就是说,表达式完全匹配搜索字符串:

    regex rx("[at]"); // search for either a or t 
    cout << boolalpha; 
    cout << regex_match("a", rx) << "n";  // true 
    cout << regex_match("a", rx) << "n";  // true 
    cout << regex_match("at", rx) << "n"; // false

在前面的代码中,搜索表达式是给定范围内的单个字符(at),因此前两个regex_match调用返回true,因为搜索的字符串是一个字符。最后一个调用返回false,因为匹配与搜索的字符串不同。如果在正则表达式中删除[],那么只有第三个调用返回true,因为您要查找确切的字符串at。如果正则表达式是[at]+,这样您要查找一个或多个字符at,那么所有三个调用都返回true。您可以通过传递match_flag_type枚举中的一个或多个常量来改变匹配的方式。

如果将match_results对象的引用传递给此函数,那么在搜索之后,该对象将包含有关匹配位置和字符串的信息。match_results对象是sub_match对象的容器。如果函数成功,这意味着整个搜索字符串与表达式匹配,在这种情况下,返回的第一个sub_match项将是整个搜索字符串。如果表达式有子组(用括号标识的模式),那么这些子组将是match_results对象中的其他sub_match对象。

    string str("trumpet"); 
    regex rx("(trump)(.*)"); 
    match_results<string::const_iterator> sm; 
    if (regex_match(str, sm, rx)) 
    { 
        cout << "the matches were: "; 
        for (unsigned i = 0; i < sm.size(); ++i)  
        { 
            cout << "[" << sm[i] << "," << sm.position(i) << "] "; 
        } 
        cout << "n"; 
    } // the matches were: [trumpet,0] [trump,0] [et,5]

在这里,表达式是字面量trump后面跟着任意数量的字符。整个字符串与此表达式匹配,并且有两个子组:字面字符串trump和在trump被移除后剩下的任何内容。

match_results类和sub_match类都是基于用于指示匹配项的迭代器类型进行模板化的。有typedef调用cmatchwcmatch,其中模板参数是const char*const wchar_t*smatchwsmatch,其中参数是在stringwstring对象中使用的迭代器,分别(类似地,还有子匹配类:csub_matchwcsub_matchssub_matchwssub_match)。

regex_match函数可能会非常严格,因为它寻找模式和搜索字符串之间的精确匹配。regex_search函数更加灵活,因为它返回true,如果搜索字符串中有与表达式匹配的子字符串。请注意,即使在搜索字符串中有多个匹配项,regex_search函数也只会找到第一个。如果要解析字符串,必须多次调用该函数,直到它指示没有更多的匹配项为止。这就是具有对搜索字符串的迭代器访问的重载变得有用的地方:

    regex rx("bd{2}b"); 
    smatch mr; 
    string str = "1 4 10 42 100 999"; 
    string::const_iterator cit = str.begin(); 
    while (regex_search(cit, str.cend(), mr, rx)) 
    { 
        cout << mr[0] << "n"; 
        cit += mr.position() + mr.length(); 
    }

在这里,表达式将匹配由空格包围的 2 位数(d{2}),两个b模式表示匹配项之前和之后的边界。循环从指向字符串开头的迭代器开始,当找到匹配项时,该迭代器将增加到该位置,然后增加匹配项的长度。regex_iterator对象,稍后解释,包装了这种行为。

match_results类为包含的sub_match对象提供了迭代器访问,因此您可以使用范围for。最初,似乎容器的工作方式有些奇怪,因为它知道sub_match对象在搜索字符串中的位置(通过position方法,该方法接受子匹配对象的索引),但sub_match对象似乎只知道它所引用的字符串。然而,仔细检查sub_match类后,可以发现它是从pair派生而来的,其中两个参数都是字符串迭代器。这意味着sub_match对象具有指定原始字符串中子字符串范围的迭代器。match_result对象知道原始字符串的起始位置,并且可以使用sub_match.first迭代器来确定子字符串的起始字符位置。

match_result对象具有[]运算符(和str方法),返回指定组的子字符串;这将是使用原始字符串中字符范围的迭代器构造的字符串。prefix方法返回匹配项之前的字符串,suffix方法返回匹配项之后的字符串。因此,在前面的代码中,第一个匹配项将是10,前缀将是1 4,后缀将是42 100 999。相比之下,如果访问sub_match对象本身,它只知道它的长度和字符串,这是通过调用str方法获得的。

match_result对象还可以通过format方法返回结果。这需要一个格式字符串,其中通过$符号标识的编号占位符标识匹配的组($1$2等)。输出可以是流,也可以从方法中作为字符串返回:

    string str("trumpet"); 
    regex rx("(trump)(.*)"); 
    match_results<string::const_iterator> sm; 
    if (regex_match(str, sm, rx)) 
    { 
        string fmt = "Results: [$1] [$2]"; 
        cout << sm.format(fmt) << "n"; 
    } // Results: [trump] [et]

使用regex_matchregex_search,您可以使用括号来标识子组。如果模式匹配,则可以使用适当的match_results对象通过引用获取这些子组。如前所示,match_results对象是sub_match对象的容器。子匹配可以使用<!===<=>>=运算符进行比较,这些运算符比较迭代器指向的项目(即子字符串)。此外,sub_match对象可以插入到流中。

使用迭代器

该库还为正则表达式提供了一个迭代器类,它提供了一种不同的解析字符串的方式。由于该类涉及字符串的比较,因此它使用元素类型和特性进行模板化。该类需要迭代字符串,因此第一个模板参数是字符串迭代器类型,元素和特性类型可以从中推导出来。regex_iterator类是一个前向迭代器,因此它具有++运算符,并且提供了一个*运算符,用于访问match_result对象。在先前的代码中,您看到match_result对象被传递给regex_matchregex_search函数,它们用于包含它们的结果。这引发了一个问题,即通过regex_iterator访问的match_result对象是由什么代码填充的。答案在于迭代器的++运算符:

    string str = "the cat sat on the mat in the bathroom"; 
    regex rx("(b(.at)([^ ]*)"); 
    regex_iterator<string::iterator> next(str.begin(), str.end(), rx); 
    regex_iterator<string::iterator> end; 

    for (; next != end; ++next) 
    { 
        cout << next->position() << " " << next->str() << ", "; 
    } 
    cout << "n"; 
    // 4 cat, 8 sat, 19 mat, 30 bathroom

在这段代码中,搜索包含第二个和第三个字母为at的单词的字符串。b表示模式必须位于单词的开头(.表示单词可以以任何字母开头)。这三个字符周围有一个捕获组,另一个捕获组包含一个或多个非空格字符。

迭代器对象next是使用要搜索的字符串和regex对象的迭代器构造的。++运算符本质上调用regex_search函数,同时保持执行下一次搜索的位置。如果搜索未找到模式,则运算符将返回序列结束迭代器,这是由默认构造函数创建的迭代器(在此代码中为end对象)。此代码打印出完整的匹配,因为我们使用str方法的默认参数(0)。如果您想要实际匹配的子字符串,请使用str(1),结果将是:

    4 cat, 8 sat, 19 mat, 30 bat

由于*(和->)运算符可以访问match_result对象,因此您还可以访问prefix方法以获取匹配之前的字符串,suffix方法将返回匹配之后的字符串。

regex_iterator类允许您迭代匹配的子字符串,而regex_token_iterator进一步提供了对所有子匹配的访问。在使用中,这个类与regex_iterator相同,只是在构造时不同。regex_token_iterator构造函数有一个参数,用于指示您希望通过*运算符访问哪个子匹配。值为-1表示您想要前缀,值为0表示您想要整个匹配,值为1或更高表示您想要编号的子匹配。如果愿意,您可以传递一个带有您想要的子匹配类型的int vector或 C 数组:

    using iter = regex_token_iterator<string::iterator>; 
    string str = "the cat sat on the mat in the bathroom"; 
    regex rx("b(.at)([^ ]*)");  
    iter next, end; 

    // get the text between the matches 
    next = iter(str.begin(), str.end(), rx, -1); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // the ,  ,  on the ,  in the , 

    // get the complete match 
    next = iter(str.begin(), str.end(), rx, 0); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // cat, sat, mat, bathroom, 

    // get the sub match 1 
    next = iter(str.begin(), str.end(), rx, 1); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // cat, sat, mat, bat, 

    // get the sub match 2 
    next = iter(str.begin(), str.end(), rx, 2); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // , , , hroom,

替换字符串

regex_replace 方法与其他方法类似,它接受一个字符串(C 字符串或 C++ string 对象,或者字符范围的迭代器)、一个 regex 对象和可选标志。此外,该函数有一个格式字符串,并返回一个 string。格式字符串基本上是传递给每个匹配结果的 results_match 对象的 format 方法的结果,用于正则表达式的匹配。然后,这个格式化的字符串被用作相应匹配的子字符串的替换。如果没有匹配,那么将返回搜索字符串的副本。

    string str = "use the list<int> class in the example"; 
    regex rx("b(list)(<w*> )"); 
    string result = regex_replace(str, rx, "vector$2"); 
    cout << result << "n"; // use the vector<int> class in the example

在上述代码中,我们说整个匹配的字符串(应该是由一些文本后跟 > 和空格组成的 list<)应该被替换为 vector, 后跟第二个子匹配(< 后跟一些文本后跟 > 和空格)。结果是 list<int> 将被替换为 vector<int>

使用字符串

该示例将作为文本文件读取和处理电子邮件。互联网消息格式的电子邮件将分为两部分:头部和消息主体。这是简单的处理,因此不会尝试处理 MIME 电子邮件主体格式(尽管此代码可以用作该处理的起点)。电子邮件主体将在第一个空行之后开始,互联网标准规定行不应超过 78 个字符。如果超过,它们不得超过 998 个字符。这意味着换行符(回车、换行对)用于保持此规则,并且段落的结束由空行表示。

头部更加复杂。在最简单的形式中,头部在单行上,并且采用 name:value 的形式。头部名称与头部值之间由冒号分隔。头部可以使用称为折叠空格的格式分成多行,其中将分割头部的换行符放置在空格(空格、制表符等)之前。这意味着以空格开头的行是前一行上头部的继续。头部通常包含由分号分隔的 name=value 对,因此能够分隔这些子项是有用的。有时这些子项没有值,也就是说,将有一个由分号终止的子项。

该示例将将电子邮件作为一系列字符串,并根据这些规则创建一个包含头部集合和包含主体的字符串的对象。

创建项目

为项目创建一个文件夹,并创建一个名为 email_parser.cpp 的 C++ 文件。由于此应用程序将读取文件并处理字符串,因此添加适当的库包含并添加代码以从命令行获取文件名:

    #include <iostream> 
    #include <fstream> 
    #include <string> 

    using namespace std; 

    void usage() 
    { 
        cout << "usage: email_parser file" << "n"; 
        cout << "where file is the path to a file" << "n"; 
    } 

    int main(int argc, char *argv[]) 
    { 
        if (argc <= 1) 
        { 
            usage(); 
            return 1; 
        } 

        ifstream stm; 
        stm.open(argv[1], ios_base::in); 
        if (!stm.is_open()) 
        { 
            usage(); 
            cout << "cannot open " << argv[1] << "n"; 
            return 1; 
        } 

        return 0; 
    }

头部将有一个名称和一个主体。主体可以是单个字符串,也可以是一个或多个子项。创建一个表示头部主体的类,并暂时将其视为单行。在 usage 函数之前添加以下类:

    class header_body 
    { 
        string body; 
    public: 
        header_body() = default; 
        header_body(const string& b) : body(b) {} 
        string get_body() const { return body; } 
    };

这只是将该类封装在一个 string 周围;稍后我们将添加代码来分离 body 数据成员中的子项。现在创建一个表示电子邮件的类。在 header_body 类之后添加以下代码:

    class email 
    { 
        using iter = vector<pair<string, header_body>>::iterator; 
        vector<pair<string, header_body>> headers; 
        string body; 

    public: 
        email() : body("") {} 

        // accessors 
        string get_body() const { return body; } 
        string get_headers() const; 
        iter begin() { return headers.begin(); } 
        iter end() { return headers.end(); } 

        // two stage construction 
        void parse(istream& fin); 
    private: 
        void process_headers(const vector<string>& lines); 
    };

headers 数据成员保存头部作为名称/值对。项目存储在 vector 中而不是 map 中,因为当电子邮件从邮件服务器传递到邮件服务器时,每个服务器可能会添加已存在于电子邮件中的头部,因此头部会重复。我们可以使用 multimap,但是我们将失去头部的顺序,因为 multimap 将以有助于搜索项目的顺序存储项目。

vector 保持容器中插入的项目的顺序,因此我们将按顺序解析电子邮件,这意味着 headers 数据成员将按照电子邮件中的顺序包含头部项目。添加适当的包含以便您可以使用 vector 类。

正文和标题有一个单独的字符串访问器。此外,还有访问器从 headers 数据成员返回迭代器,以便外部代码可以遍历 headers 数据成员(此类的完整实现将具有允许您按名称搜索标题的访问器,但在此示例的目的上,只允许迭代)。

该类支持两阶段构造,其中大部分工作是通过将输入流传递给 parse 方法来完成的。parse 方法将电子邮件作为 vector 对象中的一系列行读入,并调用一个私有函数 process_headers 来将这些行解释为标题。

get_headers 方法很简单:它只是遍历标题,并以 name: value 的格式将一个标题放在每一行中。添加内联函数:

    string get_headers() const 
    { 
        string all = ""; 
        for (auto a : headers) 
        { 
            all += a.first + ": " + a.second.get_body(); 
            all += "n"; 
        } 
        return all; 
    }

接下来,您需要从文件中读取电子邮件并提取正文和标题。 main 函数已经有打开文件的代码,所以创建一个 email 对象,并将文件的 ifstream 对象传递给 parse 方法。现在使用访问器打印出解析后的电子邮件。在 main 函数的末尾添加以下内容:

 email eml; eml.parse(stm); cout << eml.get_headers(); cout << "n"; cout << eml.get_body() << "n"; 

        return 0; 
    }

email 类声明之后,添加 parse 函数的定义:

    void email::parse(istream& fin) 
    { 
        string line; 
        vector<string> headerLines; 
        while (getline(fin, line)) 
        { 
            if (line.empty()) 
            { 
                // end of headers 
                break; 
            } 
            headerLines.push_back(line); 
        } 

        process_headers(headerLines); 

        while (getline(fin, line)) 
        { 
            if (line.empty()) body.append("n"); 
            else body.append(line); 
        } 
    }

这个方法很简单:它反复调用 <string> 库中的 getline 函数来读取一个 string,直到检测到换行符。在方法的前半部分,字符串存储在一个 vector 中,然后传递给 process_headers 方法。如果读取的字符串为空,意味着已经读取了空行--在这种情况下,所有标题都已经读取。在方法的后半部分,读取电子邮件的正文。

getline 函数将剥离用于将电子邮件格式化为 78 个字符行长度的换行符,因此循环只是将行附加为一个字符串。如果读取了空行,则表示段落结束,因此将换行符添加到正文字符串中。

parse 方法之后,添加 process_headers 方法:

    void email::process_headers(const vector<string>& lines) 
    { 
        string header = ""; 
        string body = ""; 
        for (string line : lines) 
        { 
            if (isspace(line[0])) body.append(line); 
            else 
            { 
                if (!header.empty()) 
                { 
                    headers.push_back(make_pair(header, body)); 
                    header.clear(); 
                    body.clear(); 
                } 

                size_t pos = line.find(':'); 
                header = line.substr(0, pos); 
                pos++; 
                while (isspace(line[pos])) pos++; 
                body = line.substr(pos); 
            } 
        } 

        if (!header.empty()) 
        { 
            headers.push_back(make_pair(header, body)); 
        } 
    }

此代码遍历集合中的每一行,当它有一个完整的标题时,将字符串拆分为名称/正文对。在循环内,第一行测试第一个字符是否为空格;如果不是,则检查 header 变量是否有值;如果有,则将名称/正文对存储在类 headers 数据成员中,然后清除 headerbody 变量。

以下代码对从集合中读取的行进行操作。此代码假定这是标题行的开头,因此在此处搜索字符串以找到冒号并在此处拆分。标题的名称在冒号之前,标题的正文(去除前导空格)在冒号之后。由于我们不知道标题正文是否会折叠到下一行,因此不存储名称/正文;相反,允许 while 循环重复一次,以便测试下一行的第一个字符是否为空格,如果是,则将其附加到正文。将名称/正文对保留到 while 循环的下一次迭代的操作意味着最后一行不会存储在循环中,因此在方法的末尾有一个测试,以查看 header 变量是否为空,如果不是,则存储名称/正文对。

现在可以编译代码(记得使用 /EHsc 开关)来测试是否有拼写错误。要测试代码,您应该将电子邮件从您的电子邮件客户端保存为文件,然后使用该文件的路径运行 email_parser 应用程序。以下是互联网消息格式 RFC 5322 中提供的示例电子邮件消息之一,您可以将其放入文本文件中以测试代码。

    Received: from x.y.test
 by example.net
 via TCP
 with ESMTP
 id ABC12345
 for <mary@example.net>;  21 Nov 1997 10:05:43 -0600
Received: from node.example by x.y.test; 21 Nov 1997 10:01:22 -0600
From: John Doe <jdoe@node.example>
To: Mary Smith <mary@example.net>
Subject: Saying Hello
Date: Fri, 21 Nov 1997 09:55:06 -0600
Message-ID: <1234@local.node.example>

This is a message just to say hello.
So, "Hello".

您可以通过电子邮件消息测试应用程序,以显示解析已考虑到标题格式,包括折叠空格。

处理标题子项

下一步是将头部内容处理为子项。为此,请在header_body类的public部分添加以下突出显示的声明:

    public: 
        header_body() = default; 
        header_body(const string& b) : body(b) {} 
        string get_body() const { return body; } 
        vector<pair<string, string>> subitems(); 
    };

每个子项将是一个名称/值对,由于子项的顺序可能很重要,因此子项存储在vector中。更改main函数,删除对get_headers的调用,而是逐个打印每个头部:

    email eml; 
    eml.parse(stm); 
    for (auto header : eml) { cout << header.first << " : "; vector<pair<string, string>> subItems = header.second.subitems(); if (subItems.size() == 0) { cout << header.second.get_body() << "n"; } else { cout << "n"; for (auto sub : subItems) { cout << "   " << sub.first; if (!sub.second.empty()) 
                cout << " = " << sub.second;         
                cout << "n"; } } } 
    cout << "n"; 
    cout << eml.get_body() << endl;

由于email类实现了beginend方法,这意味着范围for循环将调用这些方法来访问email::headers数据成员上的迭代器。每个迭代器将访问一个pair<string,header_body>对象,因此在此代码中,我们首先打印出头部名称,然后访问header_body对象上的子项。如果没有子项,头部仍将有一些文本,但不会被拆分为子项,因此我们调用get_body方法获取要打印的字符串。如果有子项,则打印出这些子项。有些项将有主体,有些将没有。如果该项有主体,则以name = value的形式打印子项。

最后一步是解析头部内容以将其拆分为子项。在header_body类下面,添加以下方法的定义:

    vector<pair<string, string>> header_body::subitems() 
    { 
        vector<pair<string, string>> subitems; 
        if (body.find(';') == body.npos) return subitems; 

        return subitems; 
    }

由于子项使用分号分隔,因此可以简单测试body字符串中是否有分号。如果没有分号,则返回一个空的vector

现在,代码必须重复解析字符串,提取子项。有几种情况需要解决。大多数子项将以“name=value;”的形式存在,因此必须提取此子项并在等号字符处拆分,并丢弃分号。

有些子项没有值,形式为name;,在这种情况下,分号被丢弃,并且使用空字符串存储子项的值。最后,头部中的最后一项可能没有以分号结尾,因此这必须考虑在内。

添加以下while循环:

    vector<pair<string, string>> subitems; 
    if (body.find(';') == body.npos) return subitems; 
    size_t start = 0;
 size_t end = start; while (end != body.npos){}

正如其名称所示,start变量是子项的起始索引,end是子项的结束索引。第一步是忽略任何空格,因此在while循环中添加:

    while (start != body.length() && isspace(body[start])) 
    { 
        start++; 
    } 
    if (start == body.length()) break;

这只是在引用空格字符的情况下递增start索引,只要它尚未达到字符串的末尾。如果达到字符串的末尾,这意味着没有更多的字符,因此循环结束。

接下来,添加以下内容以搜索=;字符并处理搜索情况之一:

    string name = ""; 
    string value = ""; 
    size_t eq = body.find('=', start); 
    end = body.find(';', start); 

    if (eq == body.npos) 
    { 
        if (end == body.npos) name = body.substr(start); 
        else name = body.substr(start, end - start); 
    } 
    else 
    {
    } 
    subitems.push_back(make_pair(name, value)); 
    start = end + 1;

如果搜索项找不到,则find方法将返回npos值。第一次调用查找=字符,第二次调用查找分号。如果找不到=,则该项没有值,只有一个名称。如果找不到分号,则意味着name是从start索引到字符串末尾的整个字符串。如果有分号,则name是从start索引到由end指示的索引(因此要复制的字符数为end-start)。如果在子项中找到=字符,则需要在此处拆分字符串,稍后将显示该代码。一旦namevalue变量被赋值,它们将被插入到subitems数据成员中,并且start索引移动到end索引之后的字符。如果end索引是npos,则start索引的值将无效,但这并不重要,因为while循环将测试end索引的值,并且如果索引是npos,则会中断循环。

最后,您需要添加当子项中有=字符时的代码。添加以下突出显示的文本:

    if (eq == body.npos) 
    { 
        if (end == body.npos) name = body.substr(start); 
        else name = body.substr(start, end - start); 
    } 
    else 
    { 
 if (end == body.npos) { name = body.substr(start, eq - start); value = body.substr(eq + 1); } else { if (eq < end) { name = body.substr(start, eq - start); value = body.substr(eq + 1, end - eq - 1); } else { name = body.substr(start, end - start); } } 
    }

第一行测试是否搜索分号失败。在这种情况下,名称是从start索引到等号字符之前的字符,值是等号后的文本直到字符串的末尾。

如果等号和分号字符有有效的索引,那么还有一种情况需要检查。可能等号字符的位置在分号之后,这种情况下意味着这个子项没有值,并且等号字符将用于后续子项。

在这一点上,您可以编译代码并使用包含电子邮件的文件进行测试。程序的输出应该是将电子邮件分割为标题和正文,每个标题分割为子项,这些子项可能是简单的字符串或name=value对。

总结

在本章中,您已经看到了支持字符串的各种 C++标准库类。您已经了解了如何从流中读取字符串,如何将字符串写入流,如何在数字和字符串之间进行转换,以及如何使用正则表达式来操作字符串。当您编写代码时,您将不可避免地花费时间来运行代码,以检查它是否符合您的规范。这将涉及提供检查算法结果的代码,将中间代码记录到调试设备的代码,当然还有在调试器下运行代码。下一章将全面讨论调试代码!