探索 C++20(六)

原文:Exploring C++20

协议:CC BY-NC-SA 4.0

四十一、声明和定义

探索 20 介绍了声明和定义之间的区别。这是一个很好的时机来提醒您区别,并探索类及其成员的声明和定义。

声明与定义

回想一下,声明为编译器提供了它需要的基本信息,这样你就可以在程序中使用名字。特别是,函数声明告诉编译器函数的名称、返回类型、参数类型和修饰符,比如constoverride

定义是一种特殊的声明,它也为实体提供了完整的实现细节。例如,函数定义包括函数声明的所有信息,以及函数体。然而,类增加了另一层复杂性,因为您可以独立于类定义本身来声明或定义类的成员。一个类定义必须声明它的所有成员。有时,您也可以将成员函数定义为类定义的一部分(这是我目前一直使用的风格),但是大多数程序员更喜欢在类内部声明成员函数,并在类定义之外单独定义成员函数。

与任何函数声明一样,成员函数声明包括返回类型(可能带有一个virtual说明符)、函数名、函数参数和一个可选的constoverride修饰符。如果函数是一个纯虚函数,你必须将= 0标记作为函数声明的一部分,并且不定义函数。

除了一些例外,该函数定义与任何其他函数定义相似。定义必须跟在声明后面,也就是说,成员函数定义必须在源文件中比声明成员函数的类定义靠后。在定义中,省略virtualoverride说明符。函数名必须以类名开头,后面是作用域操作符(::)和函数名,这样编译器就知道你定义的是哪个成员函数。编写函数体的方式与在类定义中提供函数定义的方式相同。清单 41-1 展示了一些例子。

class rational
{
public:
  rational();
  rational(int num);
  rational(int num, int den);
  void assign(int num, int den);
  int numerator() const;
  int denominator() const;
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};

rational::rational()
: rational{0}
{}

rational::rational(int num)
: numerator_{num}, denominator_{1}
{}

rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
  reduce();
}

void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}

void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}

int rational::numerator()
const
{
  return numerator_;
}

int rational::denominator()
const
{
  return denominator_;
}

rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}

Listing 41-1.Declarations and Definitions of Member Functions

因为每个函数名都以类名开头,所以完整的构造器名是rational::rational,成员函数名的形式是rational::numeratorrational::operator=等等。全名的 C++ 术语是限定名

程序员有很多理由在类外定义成员函数。下一节将介绍函数根据定义位置的不同而有所不同的一种方式,下一篇文章将详细讨论这一主题。

内嵌函数

在 Exploration 31 中,我引入了inline关键字,这是对编译器的一个提示,它应该通过尝试在调用点扩展函数来优化速度而不是大小。您也可以将inline用于成员函数。事实上,对于琐碎的函数,比如返回一个数据成员而不做其他事情的函数,使用函数inline可以提高速度和程序大小。

当您在类定义中定义一个函数时,编译器会自动添加inline关键字。如果将定义从声明中分离出来,您仍然可以通过在函数声明或定义中添加inline关键字来创建函数inline。通常的做法是将inline关键字只放在定义上,但是我建议将关键字放在两个地方,以帮助读者。

记住inline只是一个提示。编译器不必理会这个提示。现代编译器越来越擅长自己做这些决定。

我个人的指导方针是在类定义中定义单行函数。较长的函数或阅读起来复杂的函数通常属于类定义之外。有些函数太长,不适合放在类定义中,但是足够短和简单,它们应该是inline。组织的编码风格通常包括inline功能的指导方针。例如,大型项目的指令可能会避开inline功能,因为它们增加了软件组件之间的耦合。因此,inline可能仅在逐个功能的基础上被允许,当性能测量证明其需要时。

重写清单 41-1 中的 rational 类,明智地使用 inline 函数。将您的解决方案与我的进行比较,如清单 41-2 所示。

class rational
{
public:
  rational(int num) : numerator_{num}, denominator_{1} {}
  rational(rational const&) = default;
  inline rational(int num, int den);
  void assign(int num, int den);
  int numerator() const                   { return numerator_; }
  int denominator() const                 { return denominator_; }
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};

inline rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
  reduce();
}

void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}

void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}

rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}

Listing 41-2.The rational Class with inline Member Functions

不要为决定哪些功能应该inline而苦恼。当有疑问时,不要打扰。仅当性能度量显示函数被频繁调用并且函数调用开销很大时,才使用函数inline。在所有其他方面,我认为这是一个美学和清晰的问题:我发现单行函数在类定义中更容易阅读。

变量声明和定义

普通数据成员有声明,没有定义。函数和块中的局部变量有定义,但没有单独的声明。这可能有点混乱,但不要担心,我会解开它,让它变得清晰。

命名对象的定义指示编译器留出内存来存储对象的值,并生成必要的代码来初始化对象。一些对象实际上是子对象——本身不是完整的对象(在 C++ 中,完整的对象被称为完整的对象)。子对象没有自己的定义;相反,它的内存和生存期是由包含它的完整对象决定的。这就是为什么数据成员或基类没有自己的定义。相反,具有类类型的对象的定义导致为对象的所有数据成员留出内存。因此,类定义包含数据成员的声明,但不包含定义。

定义一个局部于块的变量。定义指定了对象的类型、名称、是否为const以及初始值(如果有的话)。你不能在没有定义局部变量的情况下声明它,但是还有其他类型的声明。

您可以将局部引用声明为局部变量的同义词。以与引用参数相同的方式将新名称声明为引用,但用现有对象对其进行初始化。如果引用是const,你可以使用任何表达式(合适的类型)作为初始化器。对于非const引用,你必须使用左值(还记得探索 21 中的那些吗?),如另一个变量。清单 41-3 说明了这些原则。

import <iostream>;

int main()
{
  int answer{42};    // definition of a named object, also an lvalue
  int& ref{answer};  // declaration of a reference named ref
  ref = 10;          // changes the value of answer
  std::cout << answer << '\n';
  int const& cent{ref * 10}; // declaration; must be const to initialize with expr
  std::cout << cent << '\n';
}

Listing 41-3.Declaring and Using References

局部引用不是定义,因为没有分配内存,也没有运行初始化器。相反,引用声明为旧对象创建了一个新名称。局部引用的一个常见用途是在范围for循环中。清单 41-4 展示了一个简单的程序,它将一系列单词读入一个向量,然后在向量中寻找最长的单词。它说明了局部引用的使用和限制。

import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;

int main()
{
  std::vector<std::string> data{
    std::istream_iterator<std::string>(std::cin),
    std::istream_iterator<std::string>()
  };

  // Ensure at least one string to measure.
  if (data.empty()) data.emplace_back();
  auto longest{ std::ranges::max(data,
    [](std::string const& a, std::string const& b)
    {
      return a.size() < b.size();
    })
  };
  std::cout << "Longest string is \"" << longest << "\"\n";
}

Listing 41-4.Finding the Longest String in a Data Set

如果您将string定义为一个普通变量,而不是将其声明为一个引用,程序会运行得很好,但是它也会对data的每个元素进行不必要的复制。在这个程序中,额外的副本是不相关的,不明显的,但在其他程序中,节省的成本可以累加。

所以如果本地引用这么俏皮,为什么longest不也是引用呢?如果你想冒险,那就试着改变“最长”的定义作为参考。会发生什么?



请记住,引用只是其他事物的另一个名称。你必须初始化一个引用,否则它将是一个空的名字,这是不允许的。此外,如果你将一个字符串赋给一个引用,这个赋值会修改名字后面的对象。换句话说,没有办法使一个引用指向一个不同的对象。在for循环中,stringdata的一个元素的名字,它似乎每次都能在循环中引用不同的元素,但它这样做是因为它在每次迭代中都被重新创建。在循环体内,你无法让string引用任何其他元素或者data或者任何其他字符串。因为longest在循环之外,所以无论循环迭代多少次,引用都只能有一个值。它不能被重新定义。这意味着我们必须复制最长的字符串,这似乎很浪费。幸运的是,有一个解决方案。不幸的是,它将不得不等待,因为该解决方案打开了一个巨大、丑陋、可怕的蠕虫的罐子。

静态变量

局部变量是自动的。这意味着当函数开始或进入局部块(复合语句)时,内存被分配,对象被构造。当函数返回或控制退出块时,对象被销毁,内存被回收。所有自动变量都分配在程序栈上,所以内存分配和释放是微不足道的,通常由主机平台的正常函数调用指令来处理。

记住main()就像一个函数,遵循许多与其他函数相同的规则。因此,您在main()中定义的变量似乎在程序的整个生命周期中都存在,但它们是自动变量,分配在栈上,编译器对待它们的方式与对待任何其他自动变量一样。

自动变量的行为允许像 RAII 这样的习惯用法(参见 Exploration 40 )并且极大地简化了典型的编程任务。尽管如此,它并不适合所有的编程任务。有时你需要一个变量的生命周期在函数调用中保持不变。例如,假设您需要一个为各种对象生成唯一标识号的函数。它从 1 开始一个串行计数器,并在每次发出 ID 时递增计数器。不管怎样,函数必须跟踪计数器的值,即使在它返回之后。清单 41-5 展示了一种方法。

int generate_id()
{
  static int counter{0};
  ++counter;
  return counter;
}

Listing 41-5.Generating Unique Identification Numbers

关键字static通知编译器变量不是自动的,而是静态的*。程序第一次调用generate_id()时,变量counter被初始化。内存不是自动的,也不在程序栈上分配。相反,所有的静态变量都被放在一边,所以直到程序关闭它们才会消失。当generate_id()返回时,counter没有被销毁,因此保留了它的值。*

*写一个程序多次调用 generate_id() **,看看它能不能工作,每次调用都会产生新的值。**将你的程序与我的进行比较,如清单 41-6 所示。

import <iostream>;

int generate_id()
{
  static int counter{0};
  ++counter;
  return counter;
}

int main()
{
  for (int i{0}; i != 10; ++i)
    std::cout << generate_id() << '\n';
}

Listing 41-6.Calling generate_id to Demonstrate Static Variables

您也可以在任何函数之外声明变量。因为它在所有函数之外,所以它不在任何块内;因此,它不可能是自动的,所以它的记忆必须是静态的。对于这样的变量,你不必使用static关键字。重写清单 40 - 6 来声明 counter 之外的 generate_id 函数。不要使用static关键字。向自己保证程序仍然正常工作。清单 41-7 展示了我的解决方案。

import <iostream>;

int counter;

int generate_id()
{
  ++counter;
  return counter;
}

int main()
{
  for (int i{0}; i != 10; ++i)
    std::cout << generate_id() << '\n';
}

Listing 41-7.Declaring counter Outside of the generate_id Function

与自动变量不同,所有没有初始化器的静态变量都以零开始填充,即使变量有内置类型。如果该类有自定义构造器,则调用默认构造器来初始化类类型的静态变量。因此,您不必为counter指定一个初始化器,但是如果您愿意,您可以这样做。

C++ 中的所有名字都是词汇范围的;名称仅在其作用域内可见。函数内声明的名字的作用域是包含声明的块(包括forifwhile语句的语句头)。在任何函数之外声明的名字的作用域有点复杂。变量或函数的名字是全局的,在整个程序中只能用于那个实体。另一方面,您只能在声明它的源文件中使用它,从声明点到文件的结尾。(下一篇文章将更详细地介绍如何使用多个源文件。)

在所有函数之外声明的变量的通用术语是全局变量。这不是标准的 C++ 术语,但现在已经够了。

如果全局声明counter,可以在程序的其他任何地方引用并修改,这可能不是你想要的。尽可能窄地限制每个名字的范围总是最好的。通过在generate_id中声明counter,你保证程序的其他部分不会意外地改变它的值。换句话说,如果只有一个函数必须访问一个静态变量,那么将变量的定义放在函数的本地。如果多个函数必须共享该变量,请全局定义该变量。

静态数据成员

关键字static有许多用途。您可以在类中的成员声明之前使用它来声明一个静态数据成员。静态数据成员不属于该类的任何对象,而是独立于所有对象。该类类型(和派生类型)的所有对象共享该数据成员的唯一实例。静态数据成员的一个常见用途是定义有用的常量。例如,std::string类有一个静态数据成员,npos,大致意思是“没有位置”当成员函数不能返回一个有意义的位置时,就返回npos,比如find找不到它要寻找的字符串。您还可以使用静态数据成员来存储共享数据,就像共享全局静态变量一样。但是,通过使共享变量成为数据成员,您可以使用普通的类访问级别来限制对数据成员的访问。

像定义任何其他全局变量一样定义静态数据成员,但用类名限定成员名。仅在数据成员的声明中使用static关键字,而不是在其定义中。因为静态数据成员不是对象的一部分,所以不要在构造器的初始化列表中列出它们。相反,应该像初始化普通全局变量一样初始化静态数据成员,但是要记住用类名限定成员名。使用静态数据成员时也要限定名称。清单 41-8 展示了静态数据成员的一些简单用法。

import <iostream>;
import <numeric>;

class rational {
public:
  rational();
  rational(int num);
  rational(int num, int den);
  int numerator() const { return numerator_; }
  int denominator() const { return denominator_; }
  // Some useful constants
  static const rational zero;
  static const rational one;
  static const rational pi;
private:
  void reduce()
  {
    int div{std::gcd(numerator_, denominator_)};
    numerator_ = numerator_ / div;
    denominator_ = denominator_ / div;
  }

  int numerator_;
  int denominator_;
};

rational::rational() : rational{0, 1} {}
rational::rational(int num) : numerator_{num}, denominator_{1} {}
rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
  reduce();
}

std::ostream& operator<<(std::ostream& out, rational const& r)
{
  return out << r.numerator() << '/' << r.denominator();
}

const rational rational::zero{};
const rational rational::one{1};
const rational rational::pi{355, 113};

int main()
{
  std::cout << "pi = " << rational::pi << '\n';
}

Listing 41-8.Declaring and Defining Static Data Members

清单 41-9 展示了一个更复杂的 ID 生成器中静态数据成员的一些例子。这个程序使用一个前缀作为它生成的 ID 的一部分,然后对每个 ID 的剩余部分使用一个串行计数器。在某些情况下,例如初始化一个整数,您可以在类定义中提供初始值。对于一个const值,您不需要提供单独的定义,因为编译器在编译时使用该值。对于不是const的静态数据成员,编译器还是需要为该数据成员留出内存,所以还是需要单独的定义。对于产品软件来说,每次运行使用不同的前缀是没问题的,但是会使测试变得非常复杂。因此,这个版本的程序使用固定数量 1。注释显示了预期的代码。

import <iostream>;

class generate_id
{
public:
  generate_id() : counter_{0} {}
  long next();
private:
  short counter_;
  static short prefix_;
  static short const max_counter_ = 32767;
};

short generate_id::prefix_{1};

long generate_id::next()
{
  if (counter_ == max_counter_)
    counter_ = 0;
  else
    ++counter_;
  return static_cast<long>(prefix_) * (max_counter_ + 1) + counter_;
}

int main()
{
  generate_id gen;           // Create an ID generator
  for (int i{0}; i != 10; ++i)
    std::cout << gen.next() << '\n';
}

Listing 41-9.Using Static Data Members for an ID Generator

声明者

正如您已经看到的,您可以在一个声明中定义多个变量,如下所示:

int x{42}, y{}, z{x+y};

整个声明包含三个声明符。每个声明符声明一个名字,不管这个名字是变量、函数还是类型。大多数 C++ 程序员不会在日常对话中使用这个术语,但 C++ 专家经常使用。你必须知道官方的 C++ 术语,这样如果你需要向专家寻求帮助,你就能理解他们。

了解将声明与定义分开的最重要的原因是,您可以将定义放在一个源文件中,将声明放在另一个源文件中。下一个探索展示了如何处理多个源文件。*

四十二、模块

真正的程序很少适合一个单独的源文件,我知道你已经迫不及待了,渴望探索 C++ 如何处理组成一个单独程序的多个源文件。这一探索向您展示了基础知识。不过,有一个警告:本章讨论了 C++ 20 中的全新特性,尽管编译器开发人员正在努力实现所有的新特性,但是他们还有许多特性需要实现,所有这些艰苦的工作都需要时间。您在本章中读到的内容可能无法在您的编译器中运行,至少在将来的某个版本之前无法运行。不要担心,Exploration 43 将带你回到处理多个源文件的老式方法,这种方法从 C++ 诞生的第一天就开始了。但是现在,让我们大胆地走向未来。

介绍模块

简单地说,你通过将程序分成模块来编写一个大程序。模块是 C++ 20 处理多个源文件的方式。正如函数有声明和定义一样,模块也有接口(声明)和实现(定义)。因为一个模块可以包含多个函数、类等等,所以他们有办法将一个模块分成多个文件作为实现细节。

让我们从一个简单的例子开始。让我们在模块hello中定义一个函数world()。我们的main()程序将调用那个函数。那么我们需要什么?首先,定义如清单 42-1 所示的模块。

export module hello;
import <iostream>;
export void world()
{
    std::cout << "hello, world\n";
}

Listing 42-1.Writing a Module

嗯,那很简单。关键字module通知编译器这个文件是模块的一部分。export关键字表示这是一个接口,也就是这个文件在导出符号。特别是,该模块导出了world()函数。该模块导入了<iostream>,因此world()可以完成它的工作,但这是一个实现细节。从模块外部可见的唯一信息是导出的声明,在本例中是world()。有了这个接口,您就可以编写一个程序来导入hello模块并调用world()函数。清单 42-2 向您展示了如何操作。

import hello;
int main()
{
    world();
}

Listing 42-2.Importing a Module

import声明导入一个模块,这使得模块导出的每个符号在执行导入的文件中都可用。您可以使用模块导出的任何名称,就像您已经在执行导入的文件中编写了这些函数一样。在import声明之后,hello这个名字不再有任何意义。这意味着您不需要限定world()函数的名称。就像你在与main()相同的文件中定义它一样正常调用它。

一个module声明是可选的,但是如果存在的话,它必须是文件中的第一个声明。然后是任何import声明。导入后,你不能在文件中使用任何进一步的moduleimport声明。这些简单的限制意味着您可以在程序的其他地方使用moduleimport作为普通名称,这对于使用这些名称的现有程序来说是很好的,但是新代码不应该使用它们以避免混淆。

如果一个文件没有module声明,就好像该文件以一个未命名的模块声明开始一样:

module;

未命名的模块也被称为全局模块。一个程序的main()函数存在于全局模块中,任何模块都可以向全局模块贡献声明,方法是以一个未命名的模块头开始文件,接着是普通的声明,然后是一个命名的模块声明。

所有的标准库头文件(除了那些从 C 编程语言导入的)都可以作为模块导入。这就是为什么本书的代码清单用import代表<iostream>,用#include代表<cassert>。因为模块是 C++ 20 中的新特性,所以现有代码的无数行都使用#include作为头文件。习惯看一会儿#include

C++ 20 标准没有将模块进一步引入到库实现中,但是您可以期待库作者开始将标准库的实现作为一套模块进行修补。例如,Microsoft Visual C++ 允许您导入std.core来一次导入几乎整个标准库。该标准的未来版本可能会采用该名称或类似的模块名称来打包和排列标准库,但现在坚持使用该标准,并期待库开始整齐地捆绑一个功能区和一个模块。

类别和模块

让我们尝试一个更有挑战性的例子:rational类。这很容易做到,但是引入了看不见的扭曲。清单 42-3 展示了如何定义一个包含清单 41-2 中rational类的rat模块。你可以自己填写剩下的细节。

export module rat1;
#include <cassert>
import <numeric>;
export class rational
{
public:
  rational(int num) : numerator_{num}, denominator_{1} {}
  rational(rational const&) = default;
  inline rational(int num, int den);
  void assign(int num, int den);
  int numerator() const                   { return numerator_; }
  int denominator() const                 { return denominator_; }
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};

inline rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
  reduce();
}

void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}

void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}

rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}

Listing 42-3.Defining the rational Class in a Module

std::gcd()函数在<numeric>头文件中声明,你可以包括旧的方式#include <numeric>,或者新的方式import <numeric>。对于rational类和它对std::gcd()的使用没有区别。这主要是一种风格的选择,因为您正在编写一个模块,所以您可以生活在 C++ 现代化的前沿,您也可以导入<numeric>头文件。

抛出一个module声明和一个export关键字只是第一步。回想一下 Exploration 41 中定义在类中的任何成员函数都是自动内联的。在模块外部是这样,但是在模块内部,情况就不一样了。一个模块中的函数可以内联的唯一方法是你用inline关键字显式地实现它,你可以在清单 42-4 中看到。

export module rat2;
#include <cassert>
import <numeric>;
export class rational
{
public:
  inline rational(int num) : numerator_{num}, denominator_{1} {}
  inline rational(rational const&) = default;
  inline rational(int num, int den)
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  void assign(int num, int den)
  {
    numerator_ = num;
    denominator_ = den;
    reduce();
  }
  inline int numerator() const           { return numerator_; }
  inline int denominator() const         { return denominator_; }
  rational& operator=(int num)
  {
    numerator_ = num;
    denominator_ = 1;
    return *this;
  }

private:
  void reduce()
  {
    assert(denominator_ != 0);
    if (denominator_ < 0)
    {
      denominator_ = -denominator_;
      numerator_ = -numerator_;
    }
    int div{std::gcd(numerator_, denominator_)};
    numerator_ = numerator_ / div;
    denominator_ = denominator_ / div;
  }
  int numerator_;
  int denominator_;
};

Listing 42-4.Defining the rational Class in One Declaration in a Module

如你所见,我不仅仅添加了inline关键词。我还将所有的成员函数移到了类定义中。熟悉 Java、Eiffel 和类似语言的读者可能对这种定义类的方式很熟悉。这个想法是将类的所有内容放在一个独立的部分中。

另外,inline在默认的函数中是不需要的,在这个例子中是复制构造器。当编译器自动填充任何构造器或函数时,它总是自动添加inline限定符。记住inline只是给编译器的一个建议,而不是一个要求。

这种定义简单类的风格适合rational,但是更复杂的类有时需要更复杂的解决方案。让我们朝这个方向迈出一步,将非内联函数隐藏在模块的不同部分。

隐藏实现

您可以将一个模块分成多个部分。最简单的划分就是接口和实现。首先删除非内联函数的定义,如清单 42-5 所示。

export module rat3;
export class rational
{
public:
  inline rational(int num) : numerator_{num}, denominator_{1} {}
  inline rational(rational const&) = default;
  inline rational(int num, int den)
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  void assign(int num, int den);
  inline int numerator() const           { return numerator_; }
  inline int denominator() const         { return denominator_; }
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};

Listing 42-5.Defining the rational Class in a Module Interface

注意模块接口中不再需要#includeimport了。只有reduce()函数需要这些声明。这是分离实现有助于保持接口模块整洁的方法之一。

当编译器必须导入一个模块时,它只需要模块接口。这包括每个内联函数的定义;否则,它将无法内联编译这些函数。非内联函数的定义存在于一个单独的文件中,即模块实现。这看起来非常像一个模块接口,但是没有关键字export。清单 42-6 显示了rat3模块的实现。

module rat3;
#include <cassert>
import <numeric>;
void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}
void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}

Listing 42-6.Writing a Module Implementation

函数定义看起来就像清单 42-3 中的一样。模块实现必须使用与模块接口相同的模块名称。模块实现中定义的任何函数、变量或类型对模块的所有用户都是隐藏的,除非模块接口导出该符号。

分离实现的主要优点是对实现的更改不会影响接口。例如,您可能希望更改 assert()来发出更有用的错误消息。可以想象,编译器将能够以这样一种方式编译单个模块,即改变 assert()调用不会影响接口的编译方式。但是通过分离实现模块,您也告诉了人类读者什么变化将影响模块的用户,什么变化对他们是隐藏的。

模块导出模块

一个模块可以导入另一个模块。这样做时,它可以将导入的模块隐藏为实现细节,或者可以公开导入的模块,就好像该模块是导出接口的一部分一样。例如,考虑清单 42-7 中的vital_stats类(类似于清单 35-1 中的record类,用于记录一个人的生命统计数据,包括体重指数)。

export module stats;
import <istream>;
import <ostream>;
export import <string>;

export class vital_stats
{
public:
  inline vital_stats() : height_{0}, weight_{0}, bmi_{0}, sex_{'?'}, name_{}
  {}

  bool read(std::istream& in, int num);
  void print(std::ostream& out, int threshold) const;

private:
  int compute_bmi() const; ///< Return BMI, based on height_ and weight_
  int height_;             ///< height in centimeters
  int weight_;             ///< weight in kilograms
  int bmi_;                ///< Body-mass index
  char sex_;               ///< 'M' for male or 'F' for female
  std::string name_;       ///< Person’s name
};

Listing 42-7.The vital_stats Class to Record a Person’s Vital Statistics

因为vital_stats类使用std::string,所以stats模块必须导入<string>。同样,std:<istream>中定义,std::ostream<ostream>中定义。但是 stats 模块的任何用户必须能够创建一个std::string,所以也需要使用<string>模块,所以stats也导出它。它不导出<istream><ostream>,因为 stats 的任何用户都会有自己的import声明,比如<iostream>来拾取std::cinstd::cout,这也导入了<istream><ostream>。所以stats也没必要这么做。通过在stats模块中导出所有必要的模块,您为使用它的程序员减轻了一个负担。通过不引入过多的符号,stats避免了给消费者带来过多无关符号的负担。

例如,与导出标准头文件相比,拥有模块 A、B 和 C 的大型库通常会从部件 B 和 C 中导出部件 A。编写一组简单的模块,从模块 a 中导出const double pi = 3.14159265358979323 b 模块导入导出 a 并且还导出一个函数 area() ,来计算一个圆的面积。模块 c 进出口 a 和出口 circumference() 来计算圆周。写一个 main() 程序来演示所有三个模块































清单 42-8 显示模块 a;清单 42-9 显示模块 b;清单 42-10 显示模块 c;清单 42-11 显示了主程序。

module;
import b;
import c;
import <iostream>;

int main()
{
   while (std::cin)
   {
      std::cout << "pi=" << pi << '\n';
      std::cout << "Radius=";
      double radius{};
      if (std::cin >> radius)
      {
         std::cout << "Area = " << area(radius) << '\n';
         std::cout << "Circumference = " << circumference(radius) << '\n';
      }
   }
}

Listing 42-11.Main Program Imports a, b, and c

export module c;
export import a;
export double circumference(double radius)
{
    return 2.0 * pi * radius;
}

Listing 42-10.Module c Exports circumference()

export module b;
export import a;
export double area(double radius)
{
    return pi * radius * radius;
}

Listing 42-9.Module b Exports area()

export module a;
export double constexpr pi = 3.14159265358979323;

Listing 42-8.Module a Exports pi

编译模块

因为模块是全新的,每个编译器供应商都以稍微不同的方式支持它们。编译目标文件库的简单时代已经一去不复返了。现在我们必须面对预编译模块、模块映射和其他复杂性。

因为每个编译器供应商做事情的方式都略有不同,所以我在这里只能提供一般性的建议。首先,检查你的工具是否支持模块。如果你给编译器传递一个特殊的选项,比如-fmodules,它们可能是可用的。或者你可能需要等待你最喜欢的编译器的更新版本。

由于接口和实现的分离,即使两者都在同一个源文件中,编译器也需要以某种方式存储模块的接口部分,使其对任何导入程序都可用。实现部分可以被编译成一个传统的目标文件,带有一些额外的信息,但是它很可能会被单独存储。编译导入模块的文件时,编译器必须能够找到编译后的模块接口。这很棘手,因为一个模块接口实际上可以由多个部分组成。我在探索中省略了这种复杂性,因为模块已经足够复杂了,只有模块作者需要了解这种能力。模块导入程序总是获取整个模块,这意味着编译器需要能够找到并收集所有模块的片段,以便导入程序可以使用它们,也就是说,除非编译器正在编译实现的一个片段,该片段会导入实现的另一个片段。正如我所说的,将一个模块分成几个部分太复杂了,本书无法涵盖。

你最好的选择是使用一个理解模块的 IDE,让它为你处理困难。如果 IDE 与编译器紧密相连,它应该知道模块接口文件存储在哪里,以及在导入模块时如何检索它们。这是一种全新的使用 C++ 的方式,所以即使是 IDE 供应商也可能需要做一些改变来适应模块。

有可能你的编译器实现了模块,但是标准库还没有更新,所以可以导入。在更改了标准库的导入而不是其他import声明之后,您可能能够编译并运行本探索中的示例。

既然你已经看到了 C++ 编程的未来,我很遗憾地告诉你,数十亿行 C++ 代码是在没有模块的帮助下编写的,而你,我的朋友,将不得不维护这数十亿行代码中的一小部分。您需要学习如何编写代码,而不是作为模块,而是仅仅作为#include文件,这是下一篇文章的主题。

四十三、老式的“模块”

模块是未来的发展方向,但是在未来到来之前,我们会被#include文件所束缚。目前有数十亿行 C++ 代码使用#include文件,所以你需要知道它们是如何工作的。通过一点训练,您仍然可以将接口与实现分开,并实现模块提供的大部分功能。

接口作为标题

基本原则是你可以在任何源文件中定义任何函数或全局对象。编译器不关心哪个文件包含什么。只要它对需要的每个名字都有一个声明,它就能把一个源文件编译成一个目标文件。(在术语趋同的不幸情况下,在 C++ 程序中,对象文件与对象无关。)要创建最终的程序,你必须把所有的目标文件链接在一起。链接器不关心哪个文件包含哪个定义;它只需为编译器生成的每个名称引用找到一个定义。

前面的探索将rational类呈现为一个接口(清单 42-5 )和一个实现(清单 42-6 )。让我们重写程序,将rational类接口放在一个名为rational.hpp的文件中,将实现放在另一个名为rational.cpp的文件中。清单 43-1 显示了rational.hpp文件。

#ifndef RATIONAL_HPP_
#define RATIONAL_HPP_

#include <iosfwd>
class rational
{
public:
  inline rational(int num) : numerator_{num}, denominator_{1} {}
  inline rational(rational const&) = default;
  inline rational(int num, int den)
  : numerator_{num}, denominator_{den}
  {
    reduce();
  }
  void assign(int num, int den);
  inline int numerator() const           { return numerator_; }
  inline int denominator() const         { return denominator_; }
  rational& operator=(int num);
private:
  void reduce();
  int numerator_;
  int denominator_;
};

std::ostream& operator<<(std::ostream&, rational const&);
#endif // RATIONAL_HPP_

Listing 43-1.The Interface Header for rational in rational.hpp

清单 43-2 显示rational.cpp

#include "rational.hpp"
#include <cassert>
#include <numeric>
#include <ostream>
void rational::assign(int num, int den)
{
  numerator_ = num;
  denominator_ = den;
  reduce();
}
void rational::reduce()
{
  assert(denominator_ != 0);
  if (denominator_ < 0)
  {
    denominator_ = -denominator_;
    numerator_ = -numerator_;
  }
  int div{std::gcd(numerator_, denominator_)};
  numerator_ = numerator_ / div;
  denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
  numerator_ = num;
  denominator_ = 1;
  return *this;
}
std::ostream& operator<<(std::ostream& stream, rational const& r)
{
    return stream << r.numerator() << '/' << r.denominator();
}

Listing 43-2.The rational Implementation in rational.cpp

要使用 rational 类,您必须#include "rational.hpp",如清单 43-3 所示。

#include <iostream>
#include "rational.hpp"

int main()
{
  rational pi{3927, 1250};
  std::cout << "pi approximately equals " << pi << '\n';
}

Listing 43-3.The main Function Using the rational Class in the main.cpp File

现在编译main.cpprational.cpp,然后将它们链接在一起,产生一个工作的 C++ 程序。如果两个源文件是同一个项目的一部分,IDE 会为您处理细节。如果使用命令行工具,可以调用相同的编译器,但是不要在命令行中列出源文件名,而只列出目标文件名。或者,通过在一次编译中列出所有源文件名,可以同时编译和链接。

这是基本的想法,但是细节,当然,有点棘手。在接下来的探索中,我们将仔细研究这些细节。

内嵌或非内嵌

因为清单 42-1 不是一个模块,在类声明中定义的函数是隐式内联的。但是我还是用关键字inline声明了它们。这是一个提醒读者这些函数是内联的好习惯。它还提倡一种混合的编程风格。

尽管跳跃到未来并 100%拥抱模块会很有趣,但我们必须维护现有代码并编写新代码。我们希望向前发展,而不是将我们的代码锁定在过去,所以理想的情况是编写可以在两个世界中都存在的代码。事实证明,使用模块和头文件很容易做到这一点。第一步是明确使用inline关键字。现在类定义在一个模块和一个头中工作。然后从头部创建一个模块,如清单 43-4 所示。

export module rat;
export {
    #include "rational.hpp"
}

Listing 43-4.Creating a Module from a Header

同样,使用模块使编码变得容易。export关键字可以应用于大括号括起来的块中的所有声明。在这种情况下,#include d 头包含一个类声明,但是它可以包含更多。

引号和括号

所有标准库的#include指令都使用尖括号,比如<iostream>,但是#include "rational.hpp"使用双引号。不同之处在于,尽管一些第三方库也推荐使用尖括号,但您应该只对标准库和系统头使用尖括号。其他的都用双引号。C++ 标准故意含糊不清,建议系统提供的头文件使用尖括号,其他头文件使用引号。关于命名他们的库文件以及它们是否需要尖括号或双引号,附加库的供应商都采取了不同的方法。

对于你自己的文件,重要的方面是编译器必须能够找到你所有的#include文件。最简单的方法是将它们保存在与源文件相同的目录或文件夹中。随着您的项目变得更大更复杂,您可能会想要将所有的#include文件移动到一个单独的区域。在这种情况下,您必须查阅您的编译器文档,以了解如何通知编译器有关该独立区域的信息。g++和其他 UNIX 和类 UNIX 命令行工具的用户通常使用-I(大写字母 I )选项。微软的命令行编译器使用/I。ide 有一个项目选项,你可以在列表中添加一个目录或文件夹来搜索#include文件。

对于许多编译器来说,尖括号和引号之间的唯一区别是它在哪里寻找文件。一些编译器还有其他特定于该编译器的差异。

在一个源文件中,我喜欢把所有的标准头文件按字母顺序排列在一起,先列出它们,然后是特定于程序的#include文件(也是按字母顺序)。这种组织方式让我很容易确定一个源文件#include是否是一个特殊的头文件,并帮助我根据需要添加或删除#include指令。

包括警卫

模块和#include文件的一个非常重要的区别是,一个模块可以多次导入而不会产生不良影响。但是多次使用同一个文件可能会重复该文件中的所有声明,这是不允许的。清单 43-1 用#ifndef RATIONAL_HPP_防止这种可能的错误。指令#ifndef是“if not defined”的缩写,所以第一行测试RATIONAL_HPP_是否未定义,这是没有定义的。第二行开始定义它。一个#endif关闭文件末尾的条件。如果同一个文件再次被#include d,现在RATIONAL_HPP_被定义,那么#ifndef为假,整个文件被跳过,一直到#endif。众所周知,这个 include guard 是头文件中常见的习惯用法。模块中不需要它,但它是无害的。(请记住,不要以下划线开始保护名称。我使用了一个尾部下划线来确保该名称不会与头文件可能声明的任何真实名称冲突。)

远期申报

<istream>头包含std::istream的完整声明和其他相关声明,< ostream >声明std::ostream。这些是大标题中的大类。有时候,你不需要完整的类声明。例如,在接口中声明输入或输出函数需要通知编译器std::istreamstd::ostream是类,但是编译器只需要知道实现文件中完整的类定义。

头文件<iosfwd>是一个小头文件,它声明了名字std::istreamstd::ostream等等,但没有提供完整的类声明。因此,通过将<istream><ostream>改为<iosfwd>,可以减少任何包含头文件的文件的编译时间。

您可以对您自己的类做同样的事情,在关键字class之后声明类名,不要用其他的东西来描述这个类:

class rational;

这就是所谓的转发声明。当编译器必须知道一个名字是一个类,但是不需要知道这个类或者这个类的任何成员的大小时,你可以使用前向声明。一个常见的情况是将一个类单独用作引用函数参数。

如果你的头使用了<iosfwd>或者其他的转发声明,确保在。cpp 源文件。

外部变量

全局变量通常不是一个好主意,但是全局常量非常有用。如果你定义了一个constexpr常量,你可以把它放在一个头中,不用再担心它。但并不是所有的常量对象都可以是constexpr。如果你需要定义一个全局常量并且不能使它成为constexpr,你需要在一个头文件中声明它,并且在一个单独的源文件中定义它,你把它和你程序的其余部分链接起来。使用extern关键字在头中声明常量。将全局常量的声明和定义分开的另一个原因是,您可能需要更改常量的值,但不想重新编译整个程序。

例如,假设您需要定义一些全局常量,以便在更大的程序中使用。程序名和全局版本号不会经常改变或者无论如何都会在程序重新构建时改变,所以可以将它们设为constexpr并在globals.hpp中声明。但是您还想声明一个名为 credits 的字符串,它包含整个项目的引用和致谢。你不想仅仅因为别人在字符串上加了一个学分就重新构建你的组件。所以信用的定义放在一个单独的globals.cpp文件中。**从编写 globals.hpp 开始,使用 include guards,对有值的 globals 使用 constexpr,对无值的 globals 使用 extern。**将你的文件与清单 43-5 进行比较。

#ifndef GLOBALS_HPP_
#define GLOBALS_HPP_

#include <string_view>

constexpr std::string_view program_name{ "The Ultimate Program" };
constexpr std::string_view program_version{ "1.0" };

extern const std::string_view program_credits;

#endif

Listing 43-5.Simple Header for Global Constants

项目中的一个源文件必须定义program_credits。将文件命名为globals.cpp globals.cpp 。将您的文件与清单 43-6 进行比较。

#include "globals.hpp"

std::string_view const program_credits{
    "Ray Lischner\n"
    "Jane Doe\n"
    "A. Nony Mouse\n"
};

Listing 43-6.Definitions of Global Constants

**发明一个程序来测试全局变量。**将您的main.cppglobals.cpp链接,创建程序。清单 43-7 展示了这样一个程序的例子。

#include <iostream>
#include "globals.hpp"

int main()

{
  std::cout << "Welcome to " << program_name << ' ' << program_version << '\n';
  std::cout << program_credits;
}

Listing 43-7.A Trivial Demonstration of globals.hpp

一定义规则

编译器强制执行一条规则,允许每个源文件有一个类、函数或对象的定义。另一个规则是在整个程序中你只能有一个函数或者全局对象的定义。如果所有源文件中的定义都相同,则可以在多个源文件中定义一个类。

内联函数遵循与普通函数不同的规则。您可以在多个源文件中定义一个内联函数。每个源文件只能有一个内联函数的定义,并且程序中的每个定义都必须相同。

这些规则统称为一个定义规则(ODR)。

编译器在单个源文件中强制执行 ODR。然而,该标准不需要编译器或链接器来检测跨越多个源文件的任何 ODR 违规。如果你犯了这样的错误,问题是你自己去发现和解决的。

假设你在维护一个程序,程序的一部分是清单 43-8 所示的头文件。

#ifndef POINT_HPP_
#define POINT_HPP_
class point
{
public:
  point() : point{0, 0} {}
  point(int x, int y) : x_{x}, y_{y} {}
  int x() const { return x_; }
  int y() const { return y_; }
private:
  int y_, x_;
};
#endif // POINT_HPP_

Listing 43-8.The Original point.hpp File

这个程序运行得很好。然而,有一天,您升级了编译器版本,当重新编译程序时,新的编译器发出了一个警告,如下所示,这是您从未见过的:

point.hpp: In constructor 'point::point()':
point.hpp:13: warning: 'point::x_' will be initialized after
point.hpp:13: warning:   'int point::y_'
point.hpp:8: warning:   when initialized here

问题是数据成员声明的顺序不同于构造器初始化列表中数据成员的顺序。这是一个小错误,但是在更复杂的类中会导致混乱,甚至更糟。确保订单一致是个好主意。假设您决定通过对数据成员重新排序来解决这个问题。

然后你重新编译程序,但是程序以神秘的方式失败了。您的回归测试有些通过了,有些失败了,包括过去从未失败过的琐碎测试。

哪里出了问题?



由于信息如此有限,您无法确定哪里出错了,但最有可能的情况是重新编译未能捕获所有的源文件。程序的某些部分(不一定是失败的部分)仍然使用旧的point类的定义,而程序的其他部分使用新的定义。该程序未能遵守 ODR,导致未定义的行为。具体来说,当程序将一个point对象从程序的一部分传递到另一部分时,程序的一部分在x_中存储一个值,另一部分读取与y_相同的数据成员。

这只是一个小小的例子,说明违反 ODR 教的行为可能既微妙又可怕。通过确保所有的类定义都在各自的头文件中,并且每次修改头文件时都要重新编译所有相关的源文件,可以避免大多数意外的 ODR 违规。

模块不会让 ODR 问题消失,但是它们会大大降低你遇到问题的可能性。因为模块是编译器知道的不同实体,并且有它们自己的语义,所以编译器可以比使用#include头文件做更多的错误检查,头文件只是文件,不携带额外的语义信息。因此,当使用模块时,编译器可能会告诉您,该实现是用不同版本的接口编译的,或者自上次编译该实现以来,某个特定类的接口发生了变化。

Tip

如果您可以编写自己的模块,我建议您这样做,即使您的工具还不完全支持标准库模块。从预发布版本来看,看起来导入标准库模块,比如import <iostream>,可能是最后一个要实现的模块方面。只是不要让那阻止你写你自己的。

既然您已经拥有了开始编写一些严肃程序所需的工具,那么是时候开始学习一些更高级的技术了。下一篇文章介绍了函数对象——一种使用标准算法的强大技术。

四十四、函数对象

在 C++ 程序中,类有很多很多的用途。这种探索引入了一种用类来代替函数的强大方法。这种编程风格对于标准算法特别有用。

函数调用运算符

第一步是看一看一个不寻常的“操作符”,即函数调用操作符,它让一个对象表现为一个函数。像重载其他运算符一样重载该运算符。它的名字叫operator()。它接受任意数量的参数,可以有任意的返回类型。清单 44-1 展示了generate_id类的另一个迭代(最后一次出现在清单 41-5 中),这次用函数调用操作符替换了next()成员函数。在这种情况下,函数没有参数,因此第一组空括号是操作符名称,第二组是空参数列表。

export module generate_id;
/// Class for generating a unique ID number.
export class generate_id
{
public:
  generate_id() : counter_{0} {}
  long operator()();
private:
  short counter_;
  static short prefix_;
  static short constexpr max_counter_{32767};
};

Listing 44-1.Rewriting generate_id to Use the Function Call Operator

清单 44-2 显示了函数调用操作符的实现(还有prefix_,它也需要一个定义)。

module generate_id;

short generate_id::prefix_{1};

long generate_id::operator()()
{
  if (counter_ == max_counter_)
    counter_ = 0;
  else
    ++counter_;
  return static_cast<long>(prefix_) * (max_counter_ + 1) + counter_;
}

Listing 44-2.Implementation of the generate_id Function Call Operator

为了使用函数调用操作符,必须首先声明一个类类型的对象,然后像使用函数名一样使用对象名。像传递普通函数一样传递参数给这个对象。编译器将对象名的使用视为一个函数,并调用函数调用操作符。清单 44-3 显示了一个使用generate_id函数调用操作符为新的库作品生成 ID 代码的示例程序。(还记得探索 39 的work班吗?将work及其派生类收集到一个模块文件中,并添加必要的import声明。调用模块library。或者从该书的网站下载完整的library.cpp。)假设int_to_id将一个整数标识转换成work要求的字符串格式,比如它调用std::to_string()

import <iostream>;

import generate_id;
import library;

bool get_movie(std::string& title, int& runtime)
{
  std::cout << "Movie title: ";
  if (not std::getline(std::cin, title))
    return false;
  std::cout << "Runtime (minutes): ";
  if (not (std::cin >> runtime))
    return false;
  return true;
}

int main()
{
  generate_id gen{};           // Create an ID generator
  std::string title{};
  int runtime{};
  while (get_movie(title, runtime))
  {
    movie m(int_to_id(gen()), title, runtime);
    std::cout << "new movie: " << m << '\n';
  }
}

Listing 44-3.Using a generate_id Object’s Function Call Operator

函数对象

函数对象函子是重载函数调用运算符的类的类类型对象。非正式地,程序员有时也将类称为“函数对象”,理解为实际的函数对象是用该类类型定义的变量。

C++ 03 程序经常使用函子,但是 C++ 11 有 lambdas,它们更容易读写。(回忆《探索》中的 lambdas23?)那么函子提供了 lambdas 所缺乏的什么呢?要回答这个问题,请考虑以下问题。

假设你需要一个包含递增值整数的向量。例如,大小为 10 的向量将包含值 1,2,3,…,8,9,10。std::generate算法接受一个迭代器范围,并为该范围的每个元素调用一个函数或仿函数,将仿函数的结果分配给连续的元素。写一个λ来作为 std::generate 的最终参数。(通过将所需大小传递给构造器,构造一个特定大小的向量。记得用括号代替花括号来调用正确的构造器。)在清单 44-4 中将您的解决方案与我的进行比较。

import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;

int main()
{
  std::vector<int> vec(10);
  int state;
  std::ranges::generate(vec, [&state]() { return ++state; });
  // Print the resulting integers, one per line.
  std::ranges::copy(vec, std::ostream_iterator<int>(std::cout, "\n"));
}

Listing 44-4.A Program for Generating Successive Integers

好的,这很简单,但是解决方案不是很通用。lambda 不能在其他任何地方重用。它需要state变量,每个状态变量只能有一个这样的 lambda。你能想出一种方法来写一个 lambda,这样你就可以有不止一个生成器,每个都有自己的状态吗?这对于 lambdas 来说很难做到,但是对于仿函数来说很容易。写一个函子类来生成连续的整数,所以这个函子可以和 generate 算法一起使用。给班级取名sequence。构造器有两个参数:第一个指定序列的初始值,第二个是增量。每次调用函数调用操作符时,它都返回生成器值,然后递增该值,这将是下一次调用函数调用操作符时返回的值。清单 44-5 显示了主程序。在单独的模块中编写您的解决方案,sequence

import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;

import sequence;

int main()
{
  int size{};
  std::cout << "How many integers do you want? ";
  std::cin >> size;
  int first{};
  std::cout << "What is the first integer? ";
  std::cin >> first;
  int step{};
  std::cout << "What is the interval between successive integers? ";
  std::cin >> step;

  std::vector<int> data(size);
  // Generate the integers to fill the vector.
  std::ranges::generate(data, sequence(first, step));

  // Print the resulting integers, one per line.
  std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}

Listing 44-5.Another Program for Generating Successive Integers

将您的解决方案与我的进行比较,如清单 44-6 所示。

export module sequence;
/// Generate a sequence of integers.
export class sequence
{
public:
  /// Construct the functor.
  /// @param start the first value the generator returns
  /// @param step increment the value by this much for each call
  inline sequence(int start, int step ) : value_{start}, step_{step} {}
  inline sequence(int start) : sequence{start, 1} {}
  inline sequence() : sequence{0} {}

  /// Return the current value in the sequence, and increment the value.
  int operator()()
  {
    int result(value_);
    value_ = value_ + step_;
    return result;
  }
private:
  int value_;
  int const step_;
};

Listing 44-6.The sequence Module

generate算法有一个伙伴generate_n,它用迭代器指定输入范围,迭代器指定范围的开始,整数指定范围的大小。下一篇文章将研究这种算法和其他几种有用的算法。

四十五、有用的算法

标准库包括一套函数,该库称之为算法,以简化许多涉及在数据范围内重复应用运算的编程任务。数据可以是对象的容器、容器的一部分、从输入流中读取的值,或者可以用迭代器表达的任何其他对象序列。我在适当的时候介绍了一些算法。这一探索对许多最有用的算法进行了更深入的研究。

本探索中的所有算法都在<algorithm>模块中定义。

范围和迭代器

到目前为止使用的大多数算法都使用范围,但是正如你在 Exploration 23 中了解到的,有时使用迭代器是有用的。在大多数情况下,相同的算法在两种风格中都可用。但是,因为范围算法在大多数编程情况下更容易使用,所以本文主要讨论范围算法。请记住,相同的算法通常也可以在迭代器中使用。有些仅以迭代器的形式存在。

范围风格的算法位于std::ranges名称空间中,这有助于保持算法的组织性。例如,下面是两种形式的copy算法,都将data的内容复制到标准输出:

std::copy(data.begin(), data.end(), std::ostream_iterator<int>(std::cout));
std::ranges::copy(data, std::ostream_iterator<int>(std::cout));

助手迭代器,比如std::ostream_iterator,在<iterator>模块中声明。范围助手,如std::ranges::istream_view,在<ranges>模块中声明。

搜索

标准算法包括多种搜索方式,分为两大类:线性和二进制。线性搜索检查一个范围内的每个元素,从第一个元素开始,继续到后续元素,直到到达末尾(或者搜索因成功而结束)。二进制搜索要求元素按照升序排序,使用<操作符,或者根据自定义谓词,即返回布尔结果的函数、仿函数或 lambda。

线性搜索算法

最基本的线性搜索是find函数。它在一系列读迭代器中搜索一个值。它返回一个迭代器,该迭代器引用范围中的第一个匹配元素。如果find找不到匹配,它返回结束迭代器的副本。清单 45-1 显示了它的使用示例。该程序将整数读入一个向量,搜索值 42,如果找到,则将该元素更改为 0。

import <algorithm>;
import <iostream>;

import data;

int main()
{
  intvector data{};
  read_data(data);
  write_data(data);
  if(auto iter{std::ranges::find(data, 42)}; iter == data.end())
    std::cout << "Value 42 not found\n";
  else
  {
    *iter = 0;
    std::cout << "Value 42 changed to 0:\n";
    write_data(data);
  }
}

Listing 45-1.Searching for an Integer

清单 45-2 显示了data模块,它提供了一些处理整数向量的工具。本探索中的大多数示例将import本模块。

export module data;

import <algorithm>;
import <iostream>;
import <iterator>;
export import <vector>;

/// Convenient shorthand for a vector of integers.
export using intvector = std::vector<int>;

/// Read a series of integers from the standard input into @p data,
/// overwriting @p data in the process.
/// @param[in,out] data a vector of integers
export void read_data(intvector& data)
{
  data.clear();
  data.insert(data.begin(), std::istream_iterator<int>(std::cin),
                            std::istream_iterator<int>());
}

/// Write a vector of integers to the standard output. Write all values on one
/// line, separated by single space characters, and surrounded by curly braces,
/// e.g., { 1 2 3 }.
/// @param data a vector of integers
export void write_data(intvector const& data)
{
  std::cout << "{ ";
  std::ranges::copy(data, std::ostream_iterator<int>(std::cout, " "));
  std::cout << "}\n";
}

Listing 45-2.The data Module to Support Integer Data

find算法相伴的是find_iffind_if不是搜索匹配值,而是采用谓词函数或函数对象(从现在开始,我将编写函子来表示自由函数、函数对象或 lambda)。它为范围内的每个元素调用仿函数,直到仿函数返回true(或者任何可以自动转换为true的值,比如非零数值)。如果仿函数从不返回 true,find_if返回结束迭代器。

每种搜索算法都有两种形式。第一个使用操作符比较条目(线性搜索使用==,二进制搜索使用<)。第二种形式使用调用方提供的函子,而不是运算符。对于大多数算法,函子是算法的附加参数,因此编译器可以区分这两种形式。在少数情况下,两种形式采用相同数量的参数,并且库使用不同的名称,因为编译器无法区分这两种形式。在这些情况下,函子形式的名称中添加了_if,例如findfind_if

假设你想搜索一个整数向量,不是一个单一的值,而是在某个范围内的任何值。您可以编写一个自定义谓词来测试硬编码的范围,但更有用的解决方案是编写一个通用的函子来比较整数和任何范围。通过将范围限制作为参数提供给构造器来使用该函子。这是自由函数、函数对象还是 lambda 的最佳实现? ________________________ 因为必须存储状态,所以我推荐写一个函子。

当您需要搜索特定值时,lambda 很好,但是如果您想要编写一个可以存储限制的通用比较器,仿函数更容易。写出了 intrange **的函子。**构造器接受两个int参数。函数调用操作符接受一个int参数。如果参数在构造器中指定的包含范围内,则返回 true 如果参数不在该范围内,则返回 false。

清单 45-3 展示了我对intrange的实现。这个范围包括低端和高端,这不同于使用半开范围的 C++ 约定。但这是确保一个范围可以跨越整组整数的最简单的方法。在半开范围中,下限和上限具有相同值的范围是表示空范围的典型方式。对于intrange,当high < low出现时,出现一个空范围。

export module intrange;

import <algorithm>;

/// Check whether an integer lies within an inclusive range.
export class intrange
{
public:
  /// Construct an integer range.
  /// If the parameters are in the wrong order,
  /// swap them to the right order.
  /// @param low the lower bound of the inclusive range
  /// @param high the upper bound of the inclusive range
  inline intrange(int low, int high)
  : low_{low}, high_{high}
  {}

  /// Check whether a value lies within the inclusive range.
  /// @param test the value to test
  inline bool operator()(int test)
  const
  {
    return test >= low_ and test <= high_;
  }
private:
  int const low_;
  int const high_;
};

Listing 45-3.Functor intrange to Generate Integers in a Certain Range

编写一个测试程序,它从标准输入中读取整数,然后使用find_ifintrange找到位于范围【10,20】内的第一个值。在清单 45-4 中将你的解决方案与我的进行比较。

import <algorithm>;
import <iostream>;
import <ranges>;

import data;
import intrange;

int main()
{
  intvector data{};
  read_data(data);
  write_data(data);
  if (auto iter{std::ranges::find_if(data, intrange{10, 20})}; iter == data.end())
    std::cout << "No values in [10,20] found\n";
  else
    std::cout << "Value " << *iter << " in range [10,20].\n";
}

Listing 45-4.Using find_if and intrange to Find an Integer That Lies Within a Range

以下几个示例生成随机数据并将算法应用于数据。标准库有一个丰富、复杂的库来生成伪随机数。这个库的细节超出了本书的范围。只有数学冒险家才应该破解<random>模块的细节。为了方便起见,清单 45-5 给出了randomint模块,该模块定义了randomint类,该类在调用者提供的范围内生成随机整数。

export module randomint;

import <algorithm>;
import <random>;

/// Generate uniformly distributed random integers in a range.
export class randomint
{
public:
  using result_type = std::default_random_engine::result_type;

  /// Construct a random-number generator to produce numbers in the range [`low`, `high`].
  /// If @p low > @p high the values are reversed.
  randomint(result_type low, result_type high)
  : prng_{},
    distribution_{std::min(low, high), std::max(low, high)}
  {}

  /// Generate the next random number generator.
  result_type operator()()
  {
     return distribution_(prng_);
  }

private:
  // implementation-defined pseudo-random-number generator
  std::default_random_engine prng_;
  // Map random numbers to a uniform distribution.
  std::uniform_int_distribution<result_type> distribution_;
};

Listing 45-5.Generating Random Integers

search函数类似于find,除了它搜索匹配的子范围。也就是说,您提供了一个搜索范围和一个要查找的值范围。search算法寻找等于整个匹配范围的元素序列的第一个匹配项,并返回一个子范围,它实际上是一对迭代器,指向找到第一个匹配项的搜索范围。如果没有找到,则子范围为空,这在if语句中被评估为假。清单 45-6 显示了一个愚蠢的程序,它生成一个 0 到 9 范围内的随机整数的大向量,然后搜索与π的前四位数字匹配的子范围。

import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;

import data;
import randomint;

int main()
{
  intvector pi{ 3, 1, 4, 1 };
  intvector data(10000, 0);
  // The randomint functor generates random numbers in the range [0, 9].
  std::ranges::generate(data, randomint{0, 9});

  auto match{std::ranges::search(data, pi)};
  if (not match)
    std::cout << "The integer range does not contain the digits of pi.\n";
  else
  {
    std::cout << "Easy as pi: ";
    std::ranges::copy(match, std::ostream_iterator<int>(std::cout, " "));
    std::cout << '\n';
  }
}

Listing 45-6.Finding a Subrange That Matches the First Four Digits of π

其他有用的线性函数包括count,它接受一个范围和值,并返回该值在该范围内出现的次数。它的对应物count_if接受一个谓词而不是一个值,并返回谓词返回 true 的次数。

另外三种算法有一个共同的模式。它们对一个范围内的每个元素应用一个谓词,并返回一个bool:

  • 如果predicate(element)range中的每个元素返回true,则all_of(range, predicate)返回true

  • 对于range中的至少一个元素,any_of(range, predicate)返回true如果predicate ( element)返回true)。

  • 如果predicate(element)range中的每个元素返回false,则none_of(range, predicate)返回true

minmaxminmax算法存在于迭代器世界中,同样位于值域中。在 C++ 20 之前,min()函数比较两个值,返回较小的一个;min_element算法采用两个迭代器,找到最小值的位置。现在,std::ranges::min()函数返回一个范围的最小值,std::ranges::min_element()也返回一个范围的最小值。max()同上。您可以猜测minmax返回了什么:一个pair迭代器,用于该范围内的最小值和最大值。这三个都是常见的重载形式:一个使用<操作符,另一个接受一个比较谓词的附加参数。

二分搜索法算法

map容器按排序顺序存储其元素,因此您可以使用任何二分搜索法算法,但是map也有成员函数,可以利用对map内部结构的访问,因此提供了改进的性能。因此,二分搜索法算法通常用于顺序容器,比如vector,当你知道它们包含排序的数据时。如果输入范围没有正确排序,结果是不确定的:您可能得到错误的答案;程序可能会崩溃;或者更糟糕的事情会发生。

binary_search函数只是测试一个排序的范围是否包含一个特定的值。默认情况下,只使用<运算符来比较值。另一种形式的binary_search将比较函子作为执行比较的附加参数。

WHAT’S IN A NAME?

find函数对单个项目执行线性搜索。search函数对匹配的一系列项目执行线性搜索。那么为什么binary_search不叫binary_find?另一方面,find_end搜索一系列值中最右边的匹配,那么为什么不叫它search_endequal功能与equal_range完全不同,尽管它们的名称相似。

C++ 标准委员会尽最大努力为算法名称应用统一的规则,例如将_if附加到采用仿函数参数但不能重载的函数上,但它面临着一些名称的历史约束。这对你来说意味着你必须将一份推荐信放在手边。不要根据名字来判断一个函数,但是在你决定是否使用这个函数之前,要阅读这个函数做什么和如何做的描述。

lower_bound函数类似于binary_search,除了它仅以迭代器的形式存在。它需要两个迭代器来界定一个输入范围,并返回一个指向该范围某处的迭代器。返回的迭代器指向值的第一个匹配项,或者如果您想将值插入到范围中并保持值的排序顺序,它指向值所属的位置。upper_bound函数类似于lower_bound,除了它返回一个迭代器,指向最后一个可以插入值并保持排序的位置。如果找到了该值,这意味着upper_bound指向该值在范围内最后一次出现后的位置。换句话说,范围[ lower_boundupper_bound]是该值在排序范围内每次出现的子范围。与任何范围一样,如果lower_bound == upper_bound,结果范围为空,这意味着该值不在搜索范围内。

清单 45-7 展示了一种排列排序输入的缓慢方法。把整数读入一个向量然后调用sort()更快。这只是使用lower_bound()upper_bound()的一个例子。额外的好处是,清单 45-7 只在向量中不存在的时候插入一个值。

import <algorithm>;
import <iostream>;
import <ranges>;

import data;

int main()
{
  intvector data{};
  int value;
  while (std::cin >> value)
  {
    auto lb{std::lower_bound(data.begin(), data.end(), value)};
    auto ub{std::upper_bound(data.begin(), data.end(), value)};
    if (lb == ub)
        // Not in data, so insert.
        data.insert(ub, value);
    // else value is already in the vector
  }
  write_data(data);
}

Listing 45-7.Using lower_bound to Create a Sorted Vector

为了更好地理解lower_boundupper_bound到底是如何工作的,写一个测试程序是有帮助的。程序可以从用户那里读入一些整数到一个向量中,对向量进行排序,然后使用lower_boundupper_bound测试一些值。为了帮助您准确理解这些函数返回的内容,调用distance函数来确定迭代器在向量中的位置,如下所示:

auto iter{std::lower_bound(data.begin(), data.end(), 42)};
std::cout << "Index of 42 is " << std::distance(data.begin(), iter) << '\n';

distance函数(在<iterator>中声明)接受一个迭代器范围,并返回该范围内的元素数量。返回类型是整数类型,尽管确切的类型(例如,intlong int)取决于实现。

编写测试程序来测试值 3、4、8、0 和 10。然后使用以下样本输入运行程序:

9 4 2 1 5 4 3 6 2 7 4

程序应该打印出什么样的排序向量?


在表 45-1 中填入每个值的下限和上限的期望值。然后运行程序检查你的答案。

表 45-1。

测试二分搜索法函数的结果

|

价值

|

预期下限

|

预期上限

|

实际下限

|

实际上限

|
| — | — | — | — | — |
| 3 |   |   |   |   |
| 4 |   |   |   |   |
| 8 |   |   |   |   |
| 0 |   |   |   |   |
| 10 |   |   |   |   |

在清单 45-8 中将你的测试程序与我的进行比较。

import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;

import data;

int main()
{
  intvector data{};
  read_data(data);
  std::ranges::sort(data);
  write_data(data);

  for (int test : { 3, 4, 8, 0, 10 })
  {
    auto lb{std::lower_bound(data.begin(), data.end(), test)};
    auto ub{std::upper_bound(data.begin(), data.end(), test)};
    std::cout << "bounds of " << test << ": { "
         << std::distance(data.begin(), lb) << ", "
         << std::distance(data.begin(), ub) << " }\n";
  }
}

Listing 45-8.Exploring the lower_bound and upper_bound Functions

编写这个程序的一个更好的方法是调用equal_range,它在一次数据传递中找到下限和上限。它返回一个迭代器的pair:pairfirst成员是下界,second是上界。

比较

要检查两个范围是否相等,即它们包含相同的值,请调用equal算法。该算法对一个范围和第二个范围的开始采用一个开始和一个结束迭代器。您必须确保这两个范围具有相同的大小。如果两个范围的每个元素都相等,则返回 true。如果有任何元素不匹配,它将返回 false。该函数有两种形式:只将迭代器传递给equal,它用==操作符比较元素;传递一个比较仿函数作为最后一个参数,equal通过调用仿函数比较元素。函子的第一个参数是来自第一个范围的元素,第二个参数是来自第二个范围的元素。

mismatch功能则相反。它比较两个范围并返回一个包含两个迭代器的对象,这两个迭代器引用第一个不匹配的元素。pair中的in1成员是引用第一个范围内元素的迭代器,in2成员是引用第二个范围的迭代器。如果两个范围相等,返回值是一对末端迭代器。

用于设置最长算法名称记录的lexicographical_compare算法。它比较两个范围,并确定第一个范围是否“小于”第二个范围。这是通过一次比较一个元素的范围来实现的。如果范围相等,函数返回 false。如果两个范围在一个范围结束时相等,而另一个范围较长,则较短的范围小于较长的范围。如果发现元素不匹配,则包含较小元素的范围就是较小的范围。使用<操作符(或调用者提供的谓词)比较所有元素,并检查它们是否等价,而不是相等。回想一下,如果下列条件成立,元素ab是等价的:

not (a < b) and not (b < a)

如果您将lexicographical_compare应用于两个字符串,您将得到预期的小于关系,这解释了名称。换句话说,如果你用字符串"hello""help"调用这个算法,它返回 true 如果用"help""hello"调用,返回 false 而如果用"hel""hello"调用,则返回 true。如果你很好奇,它把最长名字的王冠输给了它的表亲lexicographical_compare_three_way,这是一个相似的名字,但可以同时比较相等和大于。

写一个测试程序,将两个整数序列读入不同的向量。您可以通过在两组数据之间放置一个非数字字符串来做到这一点。当第一组数字到达非数字字符串时,读取失败。调用std::cin.clear()清除失败状态,读取并丢弃分隔符字符串,然后读取第二组数据。然后在两个量程上测试equalmismatchlexicographical_compare功能。

表 45-2 列出了一些建议的输入数据集。

表 45-2。

测试比较算法的建议数据集

|

数据集 1

|

数据集 2

|
| — | — |
| 1 2 3 4 5 | 1 2 3 |
| 1 2 3 | 1 2 3 4 5 |
| 1 2 3 4 5 | 1 2 4 5 |
| 1 2 3 | 1 2 3 |

在清单 45-9 中将你的测试程序与我的进行比较。

import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;

import data;

int main()
{
  intvector data1{};
  intvector data2{};

  read_data(data1);

  std::cin.clear();
  std::string discard;
  std::cin >> discard;

  read_data(data2);

  std::cout << "data1: ";
  write_data(data1);
  std::cout << "data2: ";
  write_data(data2);

  std::cout << std::boolalpha;
  std::cout << "equal(data1, data2) = " << std::ranges::equal(data1, data2) << '\n';

  auto result{std::ranges::mismatch(data1, data2)};
  std::cout << "mismatch(data1, data2) = index " <<
   std::distance(data1.begin(), result.in2) << '\n';

  std::cout << "lex_comp(data1, data2) = " <<
    std::ranges::lexicographical_compare(data1, data2) << '\n';
}

Listing 45-9Testing Various Comparison Algorithms

重新排列数据

你已经看过很多次sort算法了。其他算法也擅长重新排列一个范围内的值。merge算法将两个排序的输入范围合并成一个输出范围。与往常一样,您必须确保输出范围有足够的空间来接受来自两个输入范围的整个合并结果。这两个输入范围可以是不同的大小,所以merge有五或六个参数:两个用于第一个输入范围,两个用于第二个输入范围,一个用于输出范围的开始,还有一个可选参数供仿函数使用,以代替<运算符。

replace算法扫描输入范围,并用新值替换每次出现的旧值。替换就地发生,所以您指定了通常的范围,但没有写迭代器。replace_if函数与此类似,但它采用一个谓词而不是一个旧值。**写一个程序,读取一个整数向量并用 0 替换所有出现在[10,20]范围内的值。**重用intrange仿函数类或者写一个 lambda。将您的程序与清单 45-10 中的程序进行比较。

import <algorithm>;

import data;
import intrange;

int main()
{
  intvector data{};
  read_data(data);
  write_data(data);
  std::ranges::replace_if(data, intrange{10, 20}, 0);
  write_data(data);
}

Listing 45-10.Using replace_if and intrange to Replace All Integers in [10, 20] with 0

清单 45-11 显示了使用 lambda 的相同程序。

import <algorithm>;
import <ranges>;

import data;

int main()
{
  intvector data{};
  read_data(data);
  write_data(data);
  std::ranges::replace_if(data, [](int x) { return x >= 10 and x <= 20; }, 0);
  write_data(data);
}

Listing 45-11.Using replace_if and a Lambda to Replace All Integers in [10, 20] with 0

一个有趣的算法是shuffle,它将元素随机排列。这个函数有两个参数,指定混洗的范围和一个伪随机数生成器。对于第二个参数,使用与清单 45-5 中相同的std::default_random_engine

使用sequence模块(来自清单 44-6 )并生成一个 100 个连续整数的向量。然后随机排序并打印出来。在清单 45-12 中将你的解决方案与我的进行比较。

import <algorithm>;
import <random>;

import data;
import sequence;

int main()
{
  intvector data(100);
  std::ranges::generate(data, sequence{1, 1});
  write_data(data);
  std::ranges::shuffle(data, std::default_random_engine{});
  write_data(data);
}

Listing 45-12.Shuffling Integers into Random Order

generate算法重复调用不带参数的仿函数,并将返回值复制到输出范围。它对范围内的每个元素调用一次仿函数,覆盖每个元素。generate_n函数接受一个迭代器作为范围的开始,接受一个整数作为范围的大小。然后,它对该范围的每个元素调用一次仿函数(第三个参数),将返回值复制到该范围中。您有责任确保产品系列中确实有这么多元素。要在清单 45-12 中使用generate_n而不是generate,您可以这样写

  std::generate_n(data.begin(), data.size(), sequence{1, 1});

如果您不必为一个范围内的每一项调用仿函数,而是希望用相同值的副本填充一个范围,那么调用fill,传递一个范围和值。该值被复制到范围内的每个元素中。fill_n函数采用一个起始迭代器和一个整数大小来指定目标范围。

transform算法通过为输入范围内的每个项目调用一个仿函数来修改项目。它将变换后的结果写入输出范围,该范围可以与输入范围相同,从而就地修改范围。你已经看到这个算法在工作,所以我不会增加你已经知道的东西。该函数有两种形式:一元和二元。一元形式接受一个输入范围、一个输出范围的开始和一个函子。它为输入范围的每个元素调用仿函数,将结果复制到输出范围。输出范围可以与输入范围相同,也可以是单独的范围。与所有算法一样,您必须确保输出范围足够大以存储结果。

transform的二进制形式接受两个输入范围、一个输出范围的开始和一个二进制函子。对输入范围中的每个元素调用仿函数;第一个参数来自第一个输入范围,第二个参数来自第二个输入范围。与一元形式一样,该函数将结果复制到输出范围,该范围可以与任一输入范围相同。注意,两个输入范围的类型不必相同。

复制数据

一些算法就地运行,其他算法将其结果复制到输出范围。例如,reverse就地反转项目,reverse_copy保持输入范围不变,并将反转的项目复制到输出范围。如果一个算法的拷贝形式存在,它的名字后面会加上_copy。(除非它也是一个函数的谓词形式,在这种情况下,它在_copy后附加了_if,如replace_copy_if。)

除了您已经见过很多次的普通的copy之外,标准库还提供了copy_backward,它制作一个副本,但从结尾开始,向开头移动,保持原始顺序;copy_n,它接受一个范围的开始、一个计数和一个写迭代器;和copy_if,它类似于copy,但是接受一个谓词,并且仅当谓词返回 true 时才复制一个元素。区分copy_backwardreverse_copy。后者从输入范围的开头开始,一直到末尾,但是以相反的顺序复制值。

如果你必须移动元素而不是复制它们,调用std::movestd::move_backward。这个std::move和你在探索中遇到的 40 不一样。这个是在<algorithm>中声明的。像copy一样,move算法接受一个输入范围和一个写迭代器。它为输入范围的每个元素调用另一种形式的std::move,将元素移入输出范围。

与所有写入输出的算法一样,您有责任确保输出范围足够大,能够处理您写入其中的所有内容。标准库的一些实现提供了调试模式来帮助检测违反该规则的情况。如果你的库提供了这样的功能,无论如何,要充分利用它。

删除元素

最难使用的算法是那些“移除”元素的算法。正如你在探索 23 中学到的,像remove这样的算法实际上不会删除任何东西。相反,它们会重新排列该范围内的元素,以便将所有预定要删除的元素打包到该范围的末尾。删除算法返回一个子范围对象,其中包含该范围的剩余元素。

remove函数接受一个迭代器范围和一个值,并删除所有等于该值的元素。您还可以使用带有remove_if的谓词来删除谓词返回 true 的所有元素。这两个函数也有复制的对应物,它们不重新排列任何东西,只是复制没有被删除的元素:remove_copy复制不等于某个值的所有元素,remove_copy_if复制谓词返回 false 的所有元素。

另一种去除元素的算法是unique(和unique_copy)。它接受一个输入范围并删除所有相邻的重复项,从而确保该范围内的每一项都是唯一的。(如果范围已排序,则所有重复项都是相邻的。)这两个函数都可以使用比较函子,而不是使用默认的==操作符。

remove()返回一个子范围时,返回值实际上包含两个迭代器,这两个迭代器限定了要删除的元素的范围。复制的唯一数据是通过重新组织向量从原始范围中“删除”的值。所有的算法都以同样的方式对待任何范围,不管这个范围是一个向量、一对迭代器端点,还是任何可以用两个端点表示的东西。

写一个程序,将整数读入一个向量,将值按降序排序,擦除所有偶数元素,并在删除重复项的同时复制到一个向量。打印产生的矢量。用模数(%)运算符测试偶数:x % 2 == 0x为偶数。我的解决方案在清单 45-13 中。

import <algorithm>;
import <iterator>;
import <ranges>;

import data;
import intrange;

int main()
{
  intvector data{};
  read_data(data);
  // sort into descending order
  std::ranges::sort(data, [](int a, int b) { return b < a; });
  auto odd{ std::ranges::remove_if(data, [](int x) { return x % 2 == 0; }) };
  intvector uniquely_odd{};
  std::unique_copy(begin(data), begin(odd), std::back_inserter(uniquely_odd));
  write_data(uniquely_odd);
}

Listing 45-13.Erasing Elements from a Vector

迭代程序

算法、范围和迭代器密切相关。当描述如何使用各种迭代器、范围和子范围的算法时,我挥了挥手。在深入研究范围之前,我们需要进一步了解迭代器以及如何有效地使用它们,这是下一篇文章的主题。

四十六、关于迭代器的更多信息

迭代器提供了对一系列事物的逐个元素的访问。这些东西可以是数字、字符或几乎任何类型的对象。标准容器,比如vector,提供了对容器内容的迭代器访问,其他标准迭代器允许您访问输入流和输出流。考虑范围的一种方式是一对迭代器。标准算法需要迭代器来对事物序列进行操作。

到目前为止,您对迭代器的看法和使用还有些局限。当然,你用过它们,但是你真的了解它们吗?这种探索有助于理解迭代器到底发生了什么。

迭代器的种类

到目前为止,您已经看到迭代器有多种形式,特别是读和写。您看到了可以从一对迭代器中构造一个vector,比如std::istream_iteratorstd::ranges::copy函数需要一个写迭代器作为复制目的地。

然而,一直以来,我都通过引用“读”和“写”迭代器来简化情况。事实上,C++ 有六种不同类别的迭代器:输入、输出、正向、双向、随机访问和连续。每个类别都有额外的特征来进一步描述特定迭代器能做什么或不能做什么。输入和输出迭代器的功能最少,contiguous 的功能最多。您可以在任何需要迭代器功能较少的地方用功能较多的迭代器来代替。图 46-1 说明了迭代器的可替换性。然而,不要被这个数字误导了。它不显示类继承。使一个对象成为迭代器的是它的行为。例如,如果它满足了一个输入迭代器的所有要求,它就是一个输入迭代器,不管它是什么类型。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 46-1。

迭代器的替换树

所有迭代器都可以自由复制和赋值。复制或赋值的结果是一个新的迭代器,它引用与原始迭代器相同的项。其他特征取决于迭代器类别,如下面几节所述。

输入迭代器

不出所料,输入迭代器只支持输入。每次迭代只能从迭代器读取一次(使用一元*操作符)。您不能修改迭代器引用的项。++操作者前进到下一个输入项目。你可以比较迭代器的相等和不相等,但是唯一有意义的比较是比较一个迭代器和一个末端迭代器。一般来说,您不能比较两个输入迭代器来查看它们是否引用同一个项。

大概就是这样。输入迭代器非常有限,但也非常有用。许多标准算法用输入迭代器来表示它们的输入。istream_iterator类型是输入迭代器的一个例子。还可以将任何容器的迭代器视为输入迭代器,例如,vector 的begin()成员函数返回的迭代器。

输出迭代器

输出迭代器只支持输出。您可以通过将*操作符应用到赋值左边的迭代器来给迭代器项赋值,但是您不能从迭代器中读取。每次迭代只能修改一次迭代器值。++运算符前进到下一个输出项目。

不能比较输出迭代器是否相等。

尽管输出迭代器有局限性,但它们也被标准算法广泛使用。每个将数据复制到输出范围的算法都需要一个输出迭代器来指定范围的开始。

处理输出迭代器时需要注意的一点是,必须确保迭代器实际写入的地方有足够的空间来存储整个输出。任何错误都会导致未定义的行为。一些实现提供了可以检查这种错误的调试迭代器,当这些工具可用时,您当然应该利用它们。然而,不要仅仅依赖于调试库。在使用输出(和其他)迭代器时,仔细的代码设计、仔细的代码实现和仔细的代码审查对于确保安全是绝对必要的。

ostream_iterator类型是输出迭代器的一个例子。您也可以将许多容器的迭代器视为输出迭代器,例如,vector 的begin()成员函数返回的迭代器。

正向迭代器

前向迭代器具有输入迭代器和输出迭代器的所有功能,还有更多的功能。您可以自由地读取和写入一个迭代器项(仍然使用一元操作符*),并且您可以根据需要经常这样做。++操作符前进到下一项,==!=操作符可以比较迭代器,看它们是指同一项还是指结束位置。

一些算法需要前向迭代器,而不是输入迭代器。在之前的探索中,我忽略了这个细节,因为它很少影响到你。例如,二分搜索法算法需要前向迭代器来指定输入范围,因为它们可能需要不止一次地引用一个特定的项。这意味着你不能直接使用一个istream_iterator作为一个参数,比如说,lower_bound,但是你不太可能在一个真实的程序中尝试这样做。所有容器的迭代器都满足前向迭代器的要求,所以实际上,这个限制影响很小。

双向迭代器

双向迭代器具有正向迭代器的所有功能,但是它还支持--操作符,该操作符将迭代器向后移动一个位置,到达前一项。与任何迭代器一样,您有责任确保迭代器不会超出范围的结尾或开头。

reversereverse_copy算法(以及其他一些算法)需要双向迭代器。大多数容器的迭代器至少满足双向迭代器的要求,所以您很少需要担心这个限制。

随机存取迭代器

一个随机访问迭代器拥有所有其他迭代器的所有功能,另外你可以通过增加或减少一个整数来移动迭代器任意的数量。

你可以减去两个迭代器(假设它们引用相同的对象序列)来获得它们之间的距离。回想一下 Exploration 45 中的distance函数返回两个迭代器之间的距离。如果向函数传递正向或双向迭代器,它会一次向前推进起始迭代器一步,直到到达结束迭代器。只有这样它才会知道距离。如果你传递随机访问迭代器,它只是减去两个迭代器,并立即返回它们之间的距离。

可以比较随机访问迭代器是否相等。如果两个迭代器引用相同的对象序列,也可以使用任何关系运算符。对于随机访问迭代器,a < b意味着a引用序列中比b更早的一个项目。

sort这样的算法需要随机访问迭代器。vector类型提供了随机访问迭代器,但并不是所有的容器都提供。例如,list容器实现了一个双向链表,所以它只有双向迭代器。因为不能使用sort算法,list容器有自己的sort成员函数。在探索 56 中了解更多关于list的信息。

连续迭代器

连续迭代器具有所有其他迭代器的所有功能,而且它适用于存储在相邻内存位置的元素。向量或数组有连续的迭代器,但其他容器没有。

现在你知道了向量提供了连续的迭代器,并且你可以使用关系操作符来比较连续的(和随机访问的)迭代器,重新看看清单 10-4。你能想出一个更简单的方法来写这个程序吗?(提示:考虑一个循环条件start < end。)参见我在清单 46-1 中的重写。

import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;

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

  for (auto start{data.begin()}, end{data.end()}; start < end; ++start)
  {
    --end; // now end points to a real position, possibly start
    std::iter_swap(start, end); // swap contents of two iterators

  }

  std::copy(data.begin(), data.end(),
            std::ostream_iterator<int>(std::cout, "\n"));
}

Listing 46-1.Comparing Iterators by Using the < Operator

这次我使用了std::copy(),它使用了一对迭代器而不是一个范围,只是为了展示迭代器对是如何进行输入和输出的。

因此,输入、正向、双向、随机访问和连续迭代器都可以称为“读”迭代器,输出、正向、双向、随机访问和连续迭代器都可以称为“写”迭代器。一个算法,比如copy,可能只需要输入和输出迭代器。也就是说,输入范围需要两个输入迭代器。您可以使用任何满足输入迭代器要求的迭代器:输入迭代器、正向迭代器、双向迭代器、随机访问迭代器或连续迭代器。对于输出范围的开始,使用任何满足输出迭代器要求的迭代器:输出、前向、双向、随机访问或连续。

使用迭代器

迭代器最常见的来源是所有容器(如mapvector)提供的begin()end()成员函数。begin()成员函数返回一个引用容器第一个元素的迭代器,end()返回一个引用容器最后一个元素位置的迭代器。

begin()空集装箱返回什么?

**_____________________________________________________________

如果容器为空,begin()返回与end()相同的值,即表示“超过结尾”的特殊值,不能解引用。测试容器是否空的一种方法是测试begin() == end()。(更好的是,尤其是当你正在编写一个真正的程序,而不是试图说明迭代器的本质时,调用每个容器都提供的empty()成员函数。)

每个容器以不同的方式实现其迭代器。对你来说最重要的是迭代器满足一个标准类别的要求。

迭代器的确切类别取决于容器。返回连续的迭代器。一个map返回双向迭代器。任何库参考都会告诉你每个容器支持哪种迭代器。

许多算法和容器成员函数也返回迭代器。例如,几乎每个执行搜索的函数都返回一个指向所需项的迭代器。如果函数找不到该项,它将返回结束迭代器。返回值的类型通常与输入范围中迭代器的类型相同。将元素复制到输出范围的算法返回结果迭代器。

一旦有了迭代器,就可以用*去引用它,以获得它所引用的值(除了输出迭代器和结束迭代器,输出迭代器的去引用只是为了分配一个新值,而结束迭代器永远不能去引用)。如果迭代器引用一个对象,而你想访问该对象的一个成员,你可以使用简写的- >符号。

std::vector<std::string> lines(2, "hello");
std::string first{*lines.begin()};           // dereference the first item
std::size_t size{lines.begin()->size()};     // dereference and call a member function

您可以通过调用nextadvance函数(在<iterator>中声明)将迭代器推进到新的位置。advance函数修改作为第一个参数传递的迭代器。next函数接受迭代器的值,并返回一个新的迭代器值。第二个参数是推进迭代器的整数距离。第二个参数对于next是可选的;默认为 1。如果迭代器是随机访问的,那么这个函数会把这个距离加到迭代器上。任何其他类型的迭代器都必须多次应用它的 increment ( ++)操作符来前进所需的距离,例如:

std::vector<int> data{ 1, 2, 3, 4, 5 };
auto iter{ data.begin() };
std::cout << "4 == " << *std::next(iter, 3) << '\n';

对于一个 vector 来说,std::next()就像加法一样,但是对于其他容器,比如std::map,它多次应用 increment 运算符,以到达期望的目的地。如果你有一个反向迭代器,你可以传递一个负的距离或者调用std::prev()。如果迭代器是双向的,第二个参数可以是负的,这样就可以返回。您可以推进输入迭代器,但不能推进输出迭代器。重用 Exploration 44 中的sequence仿函数,读取清单 46-2 中的程序。

import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;

import data;       // see Listing 45-2.
import sequence;   // see Listing 44-6.

int main()
{
  intvector data(10);
  // fill with even numbers
  std::generate(data.begin(), data.end(), sequence{0, 2});
  auto iter{data.begin()};
  std::advance(iter, 4);
  std::cout << *iter << ", ";
  iter = std::prev(iter, 2);
  std::cout << *iter << '\n';
}

Listing 46-2.Advancing an Iterator

这个程序打印什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _


data向量用偶数填充,从 0 开始。迭代器iter最初指的是向量的第一个元素,即 0。迭代器前进四个位置,值为 8,然后后退两个位置,值为 4。所以输出是

8, 4

声明变量来存储迭代器是笨拙的。类型名又长又麻烦。所以我经常用auto来定义一个变量。有时你需要明确地命名一个迭代器类型。这通常是通过成员类型名来完成的。清单 46-3 展示了迭代器成员类型的一些用法。

import <iostream>;
import <string>;
import <vector>;

int main()
{
  std::vector<std::string> lines{2, "hello"};

  std::vector<std::string>::iterator iter{lines.begin()};
  *iter = "good-bye";               // dereference and modify the first item
  std::size_t size{iter->size()};   // dereference and call a member function

  std::vector<std::string>::const_iterator citer{lines.cbegin()};
  std::cout << *citer << '\n';
  std::cout << size << '\n';
}

Listing 46-3.Demonstrating Iterator Member Types

成员类型const_iterator产生容器的const元素。iterator类型产生可修改的成员。下一节将更仔细地研究const_iterator

const_iterator 与 const iterator

混淆的一个小来源是const_iteratorconst iterator之间的区别。输出迭代器(以及任何满足输出迭代器要求的迭代器,即正向迭代器、双向迭代器、随机访问迭代器和连续迭代器)允许您修改它引用的项。对于一些前向迭代器(双向的、随机访问的和连续的),您希望将该范围内的数据视为只读。即使迭代器本身满足前向迭代器的要求,您的直接需求可能只是输入迭代器。

您可能认为声明迭代器const会有所帮助。毕竟,这就是你要求编译器帮助你的方式,通过防止意外修改变量:用const说明符声明变量。你怎么想呢?行得通吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

如果你不确定,试一试。阅读清单 46-4 和预测其产量。使用与 Exploration 45 相同的data模块。

import <iostream>;
import <iterator>;
import data;

int main()
{
  intvector data{};
  read_data(data);
  const intvector::iterator iter{data.begin()};
  std::advance(iter, data.size() / 2); // move to middle of vector
  if (not data.empty())
    std::cout << "middle item = " << *iter << '\n';
}

Listing 46-4.Printing the Middle Item of a Series of Integers

你能看出为什么编译器拒绝编译程序吗?可能你看不到确切的原因,埋在编译器的错误输出里。(下一节将更详细地讨论这个问题。)错误在于变量iterconst。你不能修改迭代器,所以你不能把它推进到向量的中间。

你不必将迭代器本身声明为const,你必须告诉编译器你希望迭代器引用const数据。如果向量本身是const,那么begin()函数将返回这样一个迭代器。您可以自由地修改迭代器的位置,但是不能修改迭代器引用的值。这个函数返回的迭代器的名字是const_iterator(带下划线)。

换句话说,每个容器实际上都有两个不同的begin()函数。一个是const成员函数,返回const_iterator。另一个不是const成员函数;它返回一个普通的iterator。与任何const或非const成员函数一样,编译器根据容器本身是否为const来选择其中之一。如果容器不是const,则得到begin()的非const版本,返回一个普通的iterator,可以通过迭代器修改容器内容。如果容器是const,您将得到begin()const版本,它返回一个const_iterator,这将阻止您修改容器的内容。您还可以通过调用cbegin()来强制发布,它总是返回const_iterator,即使对于非const对象也是如此。

重写清单 46-4 以使用一个const_iterator。你的程序应该看起来类似于清单 46-5 。

import <iostream>;
import <iterator>;
import data;

int main()
{
  intvector data{};
  read_data(data);
  intvector::const_iterator iter{data.begin()};
  std::advance(iter, data.size() / 2); // move to middle of vector
  if (not data.empty())
    std::cout << "middle item = " << *iter << '\n';
}

Listing 46-5.Really Printing the Middle Item of a Series of Integers

向自己证明,有了const_iterator就不能修改数据。**对你的程序做进一步修改,取中间值。**现在你的程序应该如清单 46-6 所示。

import <iostream>;
import <iterator>;
import data;

int main()
{
  intvector data{};
  read_data(data);
  intvector::const_iterator iter{data.begin()};
  std::advance(iter, data.size() / 2); // move to middle of vector
  if (not data.empty())
    *iter = -*iter;
  write_data(data);
}

Listing 46-6.Negating the Middle Value in a Series of Integers

如果你把const_iterator改成iterator,程序就工作了。做吧。

错误消息

当你编译清单 46-4 时,编译器发出一条错误消息,或者 C++ 标准编写者称之为诊断。例如,我每天使用的编译器 g++ 会打印以下内容:

In file included from /usr/include/c++/10/bits/stl_algobase.h:66,
                 from /usr/include/c++/10/bits/char_traits.h:39,
                 from /usr/include/c++/10/ios:40,
                 from /usr/include/c++/10/ostream:38,
                 from /usr/include/c++/10/iostream:39,
                 from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator_base_funcs.h: In instantiation of 'constexpr void std::__advance(_RandomAccessIterator&, _Distance, std::random_access_iterator_tag) [with _RandomAccessIterator = const __gnu_cxx::__normal_iterator<int*, std::vector<int> >; _Distance = long int]':
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:206:21:   required from 'constexpr void std::advance(_InputIterator&, _Distance) [with _InputIterator = const __gnu_cxx::__normal_iterator<int*, std::vector<int> >; _Distance = long unsigned int]'
list4604.cc:11:37:   required from here
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:181:2: error: passing 'const __gnu_cxx::__normal_iterator<int*, std::vector<int> >' as 'this' argument discards qualifiers [-fpermissive]
  181 |  ++__i;
      |  ^~~~~
In file included from /usr/include/c++/10/bits/stl_algobase.h:67,
                 from /usr/include/c++/10/bits/char_traits.h:39,
                 from /usr/include/c++/10/ios:40,
                 from /usr/include/c++/10/ostream:38,
                 from /usr/include/c++/10/iostream:39,
                 from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator.h:975:7: note:   in call to 'constexpr __gnu_cxx::__normal_iterator<_Iterator, _Container>& __gnu_cxx::__normal_iterator<_Iterator, _Container>::operator++() [with _Iterator = int*; _Container = std::vector<int>]'
  975 |       operator++() _GLIBCXX_NOEXCEPT
      |       ^~~~~~~~
In file included from /usr/include/c++/10/bits/stl_algobase.h:66,
                 from /usr/include/c++/10/bits/char_traits.h:39,
                 from /usr/include/c++/10/ios:40,
                 from /usr/include/c++/10/ostream:38,
                 from /usr/include/c++/10/iostream:39,
                 from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:183:2: error: passing 'const __gnu_cxx::__normal_iterator<int*, std::vector<int> >' as 'this' argument discards qualifiers [-fpermissive]

那么这些官样文章是什么意思呢?虽然一个 C++ 高手也能搞清楚,但对你的帮助可能不大。隐藏在中间的是行号和源文件,它们标识了错误的来源。这就是你要开始寻找的地方。编译器直到开始处理各种模块时才发现错误。文件名取决于标准库的实现,所以您不能总是从这些文件名中判断出实际的错误是什么。

在这种情况下,当std::advance函数试图将++操作符应用到迭代器时,就会出现错误。这时编译器检测到它有一个const迭代器,但是它没有任何与const迭代器一起工作的函数。关于“丢弃限定词”的消息意味着编译器能够继续的唯一方法是去掉iter对象上的const限定词。

不要放弃理解 C++ 编译器错误信息的希望。到本书结束时,你将获得更多的知识,这将帮助你理解编译器和库是如何工作的,这种理解将帮助你理解这些错误信息。

我的建议是,处理大量令人困惑的错误信息时,首先要找到你的源文件。这应该会告诉您引起问题的行号。检查源文件。你可能会发现一个明显的错误。如果没有,请检查错误消息文本。忽略“从此处实例化”和类似的消息。尝试找到真正的错误消息,它通常以error:开始,而不是以warning:note:开始。

特化迭代器

<iterator>头定义了许多有用的、专门的迭代器,比如back_inserter,你已经见过几次了。严格来说,back_inserter是一个返回迭代器的函数,但是你很少需要知道确切的迭代器类型。

除了back_inserter,还可以使用front_inserter,它也是以容器为参数,返回一个输出迭代器。每次给解引用迭代器赋值时,它都会调用容器的push_front成员函数,将值插入容器的开头。

inserter函数接受一个容器和一个迭代器作为参数。它返回一个调用容器的insert函数的输出迭代器。insert成员函数需要一个iterator参数,指定插入值的位置。inserter迭代器最初传递第二个参数作为插入位置。在每次插入之后,它更新它的内部迭代器,所以后续的插入会进入后续的位置。换句话说,inserter只是做正确的事情。

其他专门的迭代器包括istream_iteratorostream_iterator,您也已经看到了。一个istream_iterator是一个输入迭代器,当你解引用迭代器时,它从流中提取值。在没有参数的情况下,istream_iterator构造器创建一个流尾迭代器。当输入操作失败时,迭代器相当于流尾迭代器。

一个ostream_iterator是一个输出迭代器。构造器将输出流和可选字符串作为参数。赋值给被解引用的迭代器会将一个值写入输出流,可选地后跟字符串(来自构造器)。

另一个专门的迭代器是reverse_iterator类。它采用了一个现有的迭代器(称为基础迭代器),这个迭代器必须是双向的(或者是随机访问的或者是连续的)。当反向迭代器前进时(++),基迭代器后退(--)。支持双向迭代器的容器有rbegin()rend()成员函数,它们返回反向迭代器。rbegin()函数返回一个反向迭代器,指向容器的最后一个元素,rend()返回一个特殊的反向迭代器值,表示容器开头之前的一个位置。因此,您将范围[ rbegin()rend()]视为普通的迭代器范围,以逆序表示容器的值。

C++ 不允许迭代器指向开头之前的一个位置,所以反向迭代器的实现有点古怪。通常,实现细节并不重要,但是reverse_iterator在其返回基本迭代器的base()成员函数中公开了这个特殊的细节。

我可以告诉你基本迭代器实际上是什么,但那会让你失去乐趣。写一个程序来揭示 reverse_iterator 的基本迭代器的本质。(提示:用整数序列填充一个向量。使用反向迭代器获得中间值。与迭代器的base()迭代器的值进行比较。)

如果一个 reverse_iterator 指向一个容器的位置 x,那么它的 base() 迭代器指向什么?


如果您没有回答 x + 1,请尝试运行清单 46-7 中的程序。

import <algorithm>;
import <iostream>;

import data;
import sequence;

int main()
{
  intvector data(10);
  std::generate(data.begin(), data.end(), sequence(1));
  write_data(data);                               // prints { 1 2 3 4 5 6 7 8 9 10 }
  intvector::iterator iter{data.begin()};
  iter = iter + 4;                                // iter is contiguous
  std::cout << *iter << '\n';                     // prints 5

  intvector::reverse_iterator rev{data.rbegin()};
  std::cout << *rev << '\n';                      // prints 10
  rev = rev + 4;                                  // rev is also contiguous
  std::cout << *rev << '\n';                      // prints 6
  std::cout << *rev.base() << '\n';               // prints 7
  std::cout << *data.rend().base() << '\n';       // prints 1
}

Listing 46-7.Revealing the Implementation of reverse_iterator

现在你明白了吗?基本迭代器总是指向反向迭代器位置之后的一个位置*。这就是允许rend()指向“开始之前”的位置的诀窍,尽管这是不允许的。在幕后,rend()迭代器实际上有一个指向容器中第一项的基迭代器,reverse_iterator*操作符的实现执行了获取基迭代器,后退一个位置,然后解引用基迭代器的魔术。*

如您所见,迭代器比最初看起来要复杂一些。然而,一旦你理解了它们是如何工作的,你会发现它们实际上非常简单,功能强大,并且易于使用。迭代器是范围库的基础。您可以将一个范围看作一对迭代器,但它们比这稍微复杂一些,下一篇文章将对此进行解释。**

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值