探索-C--20-九-

119 阅读45分钟

探索 C++20(九)

原文:Exploring C++20

协议:CC BY-NC-SA 4.0

五十八、区域设置和方面

正如你在 Exploration 18 中看到的,C++ 提供了一个复杂的系统来支持你的代码的国际化和本地化。即使您不打算将程序翻译成多种语言,您也必须了解 C++ 使用的语言环境机制。事实上,您一直在使用它,因为 C++ 总是通过 locale 系统发送格式化的 I/O。这种探索将帮助您更好地理解区域设置,并在您的程序中更有效地使用它们。

问题

巴别塔的故事引起了程序员的共鸣。想象一个说单一语言和使用单一字母表的世界。如果我们不必处理字符集问题、语言规则或地区,编程将会简单得多。

现实世界有许多语言、无数的字母和音节表以及众多的字符集,所有这些都使生活更加丰富和有趣,也使程序员的工作更加困难。不管怎样,我们程序员必须应付。这并不容易,这次探索不能给你所有的答案,但这是一个开始。

不同的文化、语言和字符集会产生不同的信息呈现和解释方法、不同的字符代码解释(正如您在 Exploration 18 中了解到的),以及不同的信息组织(尤其是分类)方式。即使是数字数据,您可能会发现,根据当地的环境、文化和语言,您必须以几种方式书写相同的数字。表格 58-1 展示了一些根据不同文化、习俗和地区书写数字的方法。

表 58-1。

写数字的各种方法

|

数字

|

文化

| | --- | --- | | 123456.7890 | 默认 C++ | | 123,456.7890 | 美国 | | 123 456.7890 | 国际科学 | | 卢比 1,23,456.7890 | 印度货币 * | | 123.456,7890 | 德国 |

*** 是的,逗号是正确的。

其他文化差异包括

  • 12 小时。24 小时制

  • 时区

  • 夏令时实践

  • 重音字符相对于非重音字符是如何排序的('a''á'之前还是之后)?)

  • 日期格式:月/日/年、日/月/年或年-月-日

  • 货币格式(123,456 或 99)

不知何故,糟糕的应用程序程序员必须弄清楚什么是文化相关的,收集应用程序可能运行的所有可能的文化的信息,并在应用程序中适当地使用这些信息。幸运的是,艰苦的工作已经为您完成了,并且是 C++ 标准库的一部分。

救援地点

C++ 使用一个名为 locales 的系统来管理这种风格差异。探索 18 引入了语言环境作为组织字符集及其属性的手段。地区还组织数字、货币、日期和时间的格式(加上一些我不会深入讨论的东西)。

C++ 定义了一个基本的语言环境,称为经典语言环境,它提供了最少的格式。每个 C++ 实现都可以自由地提供额外的语言环境。每个语言环境通常都有一个名称,但是 C++ 标准并没有强制要求任何特定的命名约定,这使得编写可移植代码变得很困难。您只能依赖两个标准名称:

  • 经典的地点被命名为"C"。经典语言环境为所有实现指定了相同的基本格式信息。当程序启动时,经典区域设置是初始区域设置。

  • 空字符串("")表示默认的或本地语言环境。默认区域设置从主机操作系统获取格式和其他信息,获取方式取决于操作系统所能提供的内容。对于传统的桌面操作系统,您可以假设默认区域设置指定了用户首选的格式规则和字符集信息。对于其他环境,如嵌入式系统,默认的语言环境可能与经典的语言环境相同。

许多 C++ 实现使用 ISO 和 POSIX 标准来命名地区:语言的 ISO 639 代码(例如,fr代表法语,en代表英语,ko代表韩语),可选地后跟下划线和地区的 ISO 3166 代码(例如,CH代表瑞士,GB代表英国,HK代表香港)。该名称可选地后跟一个点和字符集的名称(例如,utf8用于 Unicode UTF-8,Big5用于中文 Big 5 编码)。因此,我使用en_US.utf8作为我的默认语言环境。一个台湾本地人可能会用zh_TW.Big5;瑞士法语区的开发者可能会使用fr_CH.latin1。阅读您的库文档,了解它是如何指定区域名称的。您的默认区域设置是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _它的主要特点是什么?




每个 C++ 应用程序都有一个全局locale对象。除非您显式地更改流的区域设置,否则它会从全局区域设置开始。(如果您稍后更改了全局语言环境,这不会影响已经存在的流,例如标准 I/O 流。)最初,全局语言环境是经典语言环境。经典语言环境在任何地方都是一样的(除了依赖于字符集的部分),所以程序在经典语言环境下具有最大的可移植性。另一方面,它有最低限度的地方风味。下一节将探讨如何改变流的语言环境。

区域设置和 I/O

回想一下 Exploration 18 中,您流中注入了一个语言环境,以便根据语言环境的规则格式化 I/O。因此,为了确保以经典语言环境读取输入,并以用户的本地语言环境打印结果,您需要:

std::cin.imbue(std::locale::classic()); // standard input uses the classic locale
std::cout.imbue(std::locale{""});       // imbue with the user's default locale

标准的 I/O 流最初使用经典的语言环境。您可以在任何时候用新的语言环境来填充流,但是在执行任何 I/O 之前这样做最有意义。

通常,在读取或写入文件时,您会使用经典区域设置。您通常希望文件的内容是可移植的,并且不依赖于用户的操作系统偏好。对于控制台或 GUI 窗口的短暂输出,您可能希望使用默认的区域设置,这样用户可以最舒适地阅读和理解它。另一方面,如果另一个程序可能试图读取您程序的输出(就像 UNIX 管道和过滤器一样),您应该坚持使用传统的语言环境,以确保可移植性和通用格式。如果您准备在 GUI 中显示输出,请务必使用默认的语言环境。

面状

流解释数字输入和格式化数字输出的方式是通过请求被灌输的语言环境。一个对象是一个片段的集合,每个片段管理国际化的一个小方面。例如,一个名为numpunct的组件提供了数字格式的标点符号,比如小数点字符(在美国是'.',但在法国是',')。另一个片段,num_get,使用从numpunct获得的信息,从流中读取并解析文本以形成一个数字。num_getnumpunct等棋子称为刻面

对于普通的数值 I/O,您永远不必处理刻面。I/O 流自动为您管理这些细节:operator<<函数使用num_put方面来格式化输出的数字,而operator>>使用num_get将文本解释为数字输入。对于货币、日期和时间,I/O 操纵器使用刻面来格式化值。但是有时候你需要自己使用刻面。你在探索 18 中学到的isalphatoupper和其他与角色相关的功能都使用ctype刻面。任何必须进行大量字符测试和转换的程序都可以通过直接管理其方面而受益。

像字符串和 I/O 流一样,刻面是类模板,在字符类型上参数化。到目前为止,你唯一用过的字符类型是char;你将在探索中了解其他角色类型。不管字符类型如何,原则都是一样的(这就是刻面使用模板的原因)。

要从一个地区获得一个方面,调用use_facet函数模板。模板参数是你寻找的 facet,函数参数是locale对象。返回的方面是const,不可复制,所以使用结果的最佳方式是初始化一个const引用,如下所示:

auto const& mget{ std::use_facet<std::money_get<char>>(std::locale{""}) };

从内向外读取,名为mget的对象被初始化为调用use_facet函数的结果,该函数请求对money_get<char>方面的引用。默认语言环境作为唯一的参数传递给use_facet函数。mget的类型是对const money_get<char>刻面的引用。一开始读起来有点令人生畏,但你最终会习惯的。

直接使用刻面可能会很复杂。幸运的是,标准库提供了一些 I/O 操纵器(在<iomanip>中声明)来简化时间和货币方面的使用。清单 58-1 展示了一个简单的程序,它将标准 I/O 流融入本地语言环境,然后读取和写入货币值。

import <iomanip>;
import <iostream>;
import <locale>;
import <string>;

int main()
{
  std::locale native{""};
  std::cin.imbue(native);
  std::cout.imbue(native);

  std::cin >> std::noshowbase;  // currency symbol is optional for input
  std::cout << std::showbase;   // always write the currency symbol for output

  std::string digits;
  while (std::cin >> std::get_money(digits))
  {
    std::cout << std::put_money(digits) << '\n';
  }
  if (not std::cin.eof())
    std::cout << "Invalid input.\n";
}

Listing 58-1.Reading and Writing Currency Using the Money I/O Manipulators

区域设置操纵器像其他操纵器一样工作,但是它们调用相关的方面。操纵器使用流来处理错误标志、迭代器、填充字符等等。get_timeput_time操纵器读取和写入日期和时间;详情请查阅库参考资料。

字符类别

这一部分继续你在 18 中开始的字符集和区域设置的检查。除了测试字母数字字符或小写字符,您还可以测试几种不同的类别。表 58-2 列出了所有的分类函数以及它们在经典语言环境中的行为。都是以一个字符作为第一个参数,以一个locale作为第二个参数;它们都返回一个bool结果。

表 58-2。

字符分类功能

|

功能

|

描述

|

经典区域设置

| | --- | --- | --- | | isalnum | 含字母和数字的 | 'a''z''A''Z''0''9' | | isalpha | 字母的 | 'a''z''A''Z' | | iscntrl | 控制 | 任何不可打印的字符 | | isdigit | 手指 | '0''9'(所有地区) | | isgraph | 图解的 | 除' ' 以外的可打印字符 | | islower | 小写字母 | 'a''z' | | isprint | 可印刷的 | 字符集 中的任何可打印字符 | | ispunct | 标点 | 除字母数字或空白以外的可打印字符 | | isspace | 空格 | ' ''\f''\n''\r''\t''\v' | | isupper | 大写字母 | 'A''Z' | | isxdigit | 十六进制数字 | 'a''f''A''F''0''9'(所有地区) |

* 行为取决于字符集,即使在经典的语言环境中也是如此。

经典语言环境对一些类别有固定的定义(比如isupper)。然而,其他地区可以扩展这些定义以包含其他字符,这些字符可能(也可能)依赖于字符集。只有isdigitisxdigit对所有地区和所有字符集都有固定的定义。

然而,即使在经典的语言环境中,一些函数的精确实现,比如isprint,也依赖于字符集。例如,在流行的 ISO 8859-1 (Latin-1)字符集中,'\x80'是一个控制字符,但是在同样流行的 Windows-1252 字符集中,它是可打印的。在 UTF-8 中,'\x80'是无效的,所以所有的分类函数都将返回false

语言环境和字符集之间的交互是 C++ 表现不佳的地方之一。区域设置可以随时更改,这可能会设置一个新的字符集,从而赋予某些字符值新的含义。但是,编译器对字符集的看法是固定的。例如,编译器将'A'视为大写罗马字母 A ,并根据其运行时字符集的概念编译数字代码。该数值将永远固定不变。如果特征函数使用相同的字符集,一切都很好。isalphaisupper函数返回真;isdigit返回 false 这个世界一切都好。如果用户更改了区域设置,从而更改了字符集,那么这些函数可能不再适用于该字符变量。

让我们考虑一个具体的例子,如清单 58-2 所示。此程序对区域名称进行编码,这可能不适合您的环境。阅读评论,看看您的环境是否可以支持相同类型的语言环境,尽管名称不同。你将需要清单 40-4 中的ioflags类。将类复制到它自己的模块ioflags中,或者从书的网站下载文件。在阅读清单 58-2 ,之后,您期望的结果是什么?



import <format>;
import <iostream>;
import <locale>;
import <ostream>;

import ioflags;  // from Listing 40-4

/// Print a character's categorization in a locale.
void print(int c, std::string const& name, std::locale loc)
{
  // Don't concern yourself with the & operator. I'll cover that later
  // in the book, in Exploration 63\. Its purpose is just to ensure
  // the character's escape code is printed correctly.
  std::cout << std::format("\\x{:02x} is {} in {}\n", c & 0xff, name, loc.name());
}

/// Test a character's categorization in the locale, @p loc.
void test(char c, std::locale loc)
{
  ioflags save{std::cout};
  if (std::isalnum(c, loc))
    print(c, "alphanumeric", loc);
  else if (std::iscntrl(c, loc))
    print(c, "control", loc);
  else if (std::ispunct(c, loc))
    print(c, "punctuation", loc);
  else
    print(c, "none of the above", loc);
}

int main()
{
  // Test the same code point in different locales and character sets.
  char c{'\xd7'};

  // ISO 8859-1 is also called Latin-1 and is widely used in Western Europe
  // and the Americas. It is often the default character set in these regions.
  // The country and language are unimportant for this test.
  // Choose any that support the ISO 8859-1 character set.
  test(c, std::locale{"en_US.iso88591"});

  // ISO 8859-5 is Cyrillic. It is often the default character set in Russia
  // and some Eastern European countries. Choose any language and region that
  // support the ISO 8859-5 character set.
  test(c, std::locale{"ru_RU.iso88595"});

  // ISO 8859-7 is Greek. Choose any language and region that
  // support the ISO 8859-7 character set.
  test(c, std::locale{"el_GR.iso88597"});

  // ISO 8859-8 contains some Hebrew

. The character set is no longer widely used.
  // Choose any language and region that support the ISO 8859-8 character set.
  test(c, std::locale{"he_IL.iso88598"});
}

Listing 58-2.Exploring Character Sets and Locales

你得到的实际回应是什么?





如果您在识别区域名称或运行该程序时遇到其他问题,以下是我在系统上运行该程序时得到的结果:

\xd7 is punctuation in en_US.iso88591
\xd7 is alphanumeric in ru_RU.iso88595
\xd7 is alphanumeric in el_GR.iso88597
\xd7 is none of the above in he_IL.iso88598

正如您所看到的,相同的字符有不同的类别,这取决于地区的字符集。现在假设用户输入了一个字符串,你的程序存储了这个字符串。如果您的程序更改了全局区域设置或用于处理该字符串的区域设置,您最终可能会误解该字符串。

在清单 58-2 中,分类函数在每次被调用时都会重新加载它们的方面,但是你可以重写程序,让它只加载一次它的方面。字符型刻面叫做ctype。它有一个名为is的函数,将类别掩码和字符作为参数,如果字符在掩码中有类型,则返回一个bool : true。屏蔽值在std::ctype_base中指定。

Note

请注意标准库自始至终使用的约定。当类模板需要助手类型和常量时,它们在非模板基类中声明。类模板派生自基类,因此可以轻松访问类型和常量。调用方通过用基类名称限定来获得对类型和常量的访问权。通过避免在基类中使用模板,标准库避免了仅仅为了使用与模板参数无关的类型或常量而进行的不必要的实例化。

掩码名称与分类函数相同,但没有前导的is。清单 58-3 展示了如何重写简单的字符集演示来使用一个缓存的ctype方面。

import <format>;
import <iostream>;
import <locale>;

import ioflags;  // from Listing 40-4

void print(int c, std::string const& name, std::locale loc)
{
  // Don't concern yourself with the & operator. I'll cover that later
  // in the book. Its purpose is just to ensure the character's escape
  // code is printed correctly.
  std::cout << std::format("\\x{:02x} is {} in {}\n", c & 0xff, name, loc.name());
}

/// Test a character's categorization in the locale, @p loc.
void test(char c, std::locale loc)
{
  ioflags save{std::cout};

  std::ctype<char> const& ctype{std::use_facet<std::ctype<char>>(loc)};

  if (ctype.is(std::ctype_base::alnum, c))
    print(c, "alphanumeric", loc);
  else if (ctype.is(std::ctype_base::cntrl, c))
    print(c, "control", loc);
  else if (ctype.is(std::ctype_base::punct, c))
    print(c, "punctuation", loc);
  else
    print(c, "none of the above", loc);
}

int main()
{
  // Test the same code point

in different locales and character sets.
  char c{'\xd7'};

  // ISO 8859-1 is also called Latin-1 and is widely used in Western Europe
  // and the Americas. It is often the default character set in these regions.
  // The country and language are unimportant for this test.
  // Choose any that support the ISO 8859-1 character set.
  test(c, std::locale{"en_US.iso88591"});

  // ISO 8859-5 is Cyrillic. It is often the default character set in Russia
  // and some Eastern European countries. Choose any language and region that
  // support the ISO 8859-5 character set.
  test(c, std::locale{"ru_RU.iso88595"});

  // ISO 8859-7 is Greek. Choose any language and region that
  // support the ISO 8859-7 character set.
  test(c, std::locale{"el_GR.iso88597"});

  // ISO 8859-8 contains some Hebrew. It is no longer widely used.
  // Choose any language and region that support the ISO 8859-8 character set.
  test(c, std::locale{"he_IL.iso88598"});
}

Listing 58-3.Caching the ctype Facet

ctype方面还使用touppertolower成员函数执行大小写转换,这两个函数接受一个字符参数并返回一个字符结果。回忆探险第 22 期的计字题。重写您的解决方案(参见清单 23-2 23-3)并更改 sanitize()函数以使用缓存的 facet 。我建议用一个sanitizer类替换这个函数,这样这个类就可以在数据成员中存储这个方面。将你的程序与清单 58-4 进行比较。

import <format>;
import <iostream>;
import <locale>;
import <map>;
import <ranges>;
import <string>;
import <string_view>;

using count_map  = std::map<std::string, int>;  ///< Map words to counts
using count_pair = count_map::value_type;       ///< pair of a word and a count
using str_size   = std::string::size_type;      ///< String size type

void initialize_streams()
{
  std::cin.imbue(std::locale{});
  std::cout.imbue(std::locale{});
}

class sanitizer
{
public:
  sanitizer(std::locale const& locale)
  : ctype_{ std::use_facet<std::ctype<char>>(locale) }
  {}

  bool keep(char ch) const { return ctype_.is(ctype_.alnum, ch); }
  char tolower(char ch) const { return ctype_.tolower(ch); }

  std::string operator()(std::string_view str)
  const
  {
    auto data{ str
      | std::ranges::views::filter(this { return keep(ch); })
      | std::ranges::views::transform(this { return tolower(ch); })  };
    return std::string{ std::ranges::begin(data), std::ranges::end(data) };
  }
private:
    std::ctype<char> const& ctype_;
};

str_size get_longest_key(count_map const& map)

{
  str_size result{0};
  for (auto const& pair : map)
    if (pair.first.size() > result)
      result = pair.first.size();
  return result;
}

void print_pair(count_pair const& pair, str_size longest)
{
  int constexpr count_size{10}; // Number of places for printing the count
  std::cout << std::format("{0:{1}} {2:{3}}\n", pair.first, longest, pair.second, count_size);
}

void print_counts(count_map const& counts)
{
  auto longest{get_longest_key(counts)};

  // For each word/count pair...
  for (count_pair pair: counts)
    print_pair(pair, longest);
}

int main()
{
  // Set the global locale to the native locale.
  std::locale::global(std::locale{""});
  initialize_streams();

  count_map counts{};
  sanitizer sanitize{std::locale{""}};

  // Read words from the standard input and count the number of times

  // each word occurs.
  std::string word{};
  while (std::cin >> word)
  {
    std::string copy{sanitize(word)};

    // The "word" might be all punctuation, so the copy would be empty.
    // Don't count empty strings.
    if (not copy.empty())
      ++counts[copy];
  }

  print_counts(counts);
}

Listing 58-4.Counting Words Again, This Time with Cached Facets

请注意,程序的大部分内容都没有改变。在我的系统上,缓存ctype facet 的简单行为减少了这个程序大约 15%的运行时间。

校对顺序

可以将关系运算符(如<)与字符和字符串一起使用,但它们实际上并不比较字符或码位;他们比较存储单元。大多数用户并不关心一个名字列表是否按照存储单元按数字升序排序。他们想要一个按照他们自己的排序规则按字母升序排列的名字列表。

比如哪个先来:昂斯特伦还是角度?答案取决于你住在哪里,说什么语言。在斯堪的纳维亚,角度在前,昂斯特伦斑马后。collate方面根据地区的规则比较字符串。它的compare函数使用起来有些笨拙,所以locale类模板提供了一个简单的接口来确定一个string是否比另一个少:使用locale的函数调用操作符。换句话说,您可以使用一个locale对象本身作为标准算法的比较函子,比如sort。清单 58-5 显示了一个程序,该程序演示了排序规则如何依赖于区域设置。为了让程序在您的环境中运行,您可能需要更改区域名称。

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

void sort_words(std::vector<std::string> words, std::locale loc)
{
  std::ranges::sort(words, loc);
  std::cout << loc.name() << ":\n";
  std::ranges::copy(words,
            std::ostream_iterator<std::string>(std::cout, "\n"));
}

int main()
{
  std::vector<std::string> words{
    "circus",
    "\u00e5ngstrom",     // ångstrom
    "\u00e7irc\u00ea",   // çircê
    "angle",
    "essen",
    "ether",
    "\u00e6ther",        // æther
    "aether",
    "e\u00dfen"         // eßen
  };
  sort_words(words, std::locale::classic());
  sort_words(words, std::locale{"en_GB.utf8"});  // Great Britain
  sort_words(words, std::locale{"no_NO.utf8"});  // Norway
}

Listing 58-5.Demonstrating How Collation Order Depends on Locale

\uNNNN字符是一种表达 Unicode 字符的可移植方式。NNNN必须是四个十六进制数字,指定一个 Unicode 码位。在接下来的探索中你会学到更多。

粗体行显示了如何使用locale对象作为比较函子对单词进行排序。表 58-3 列出了我在每个地区得到的结果。根据您的本地字符集,您可能会得到不同的结果。

表 58-3。

各种语言环境的归类顺序

|

经典的

|

大不列颠

|

挪威

| | --- | --- | --- | | aether | aether | aether | | angle | æther | angle | | circus | angle | çircê | | essen | ångstrom | circus | | ether | çircê | essen | | eßen | circus | eßen | | ångstrom | essen | ether | | æther | eßen | æther | | çircê | ether | ångstrom |

下一篇文章将深入探讨 Unicode、国际字符集以及相关的挑战。

五十九、国际字符

探索 17-19 讨论了字符,但只是暗示了更大的事情即将到来。Exploration 58 开始从地区和方面来研究这些更大的问题。下一个要探讨的主题是如何在国际环境中讨论字符集和字符编码。

这个探索引入了宽字符,它类似于普通的(或)字符,除了它们通常占用更多的内存。这意味着宽字符类型可能比普通的char表示更多的字符。在探索宽字符的过程中,您还会对 Unicode 有更多的了解。

为什么宽?

正如您在 Exploration 18 中看到的,特定字符值的含义取决于地区和字符集。例如,在一个语言环境中,您可以处理希腊语字符,而在另一个语言环境中,根据字符集,您可以处理西里尔语字符。你的程序需要知道区域设置和字符集,以便确定哪些字符是字母,哪些是标点,哪些是大写或小写,以及如何将大写字母转换成小写字母和将 ?? 转换成小写字母。

如果你的程序必须处理西里尔文和希腊文怎么办?如果这个程序需要同时处理它们呢?亚洲语言呢?中文不使用西方风格的字母表,而是使用成千上万种不同的表意文字。一些亚洲语言已经采用了一些中国的表意文字。典型的char类型的实现达到了 256 个不同字符的极限,这远远不能满足国际需求。

换句话说,如果你想支持世界上大多数人和他们的语言,你不能使用普通的charstring类型。C++ 用宽字符解决了这个问题,它用几种类型来表示:wchar_tchar16_tchar32_t。(与 C 对wchar_t的定义不同,C++ 中的类型名是保留关键字和内置类型,而不是 typedefs。)其意图是wchar_t是一个原生类型,可以表示不适合char的字符。例如,对于较大的字符,程序可以支持亚洲字符集。char16_tchar32_t是 Unicode 类型。类型char8_t也适用于 Unicode,但它是一种窄字符类型。探索从检查wchar_t开始。

使用宽字符

在真正的 C++ 风格中,wchar_t的大小和其他特征留给了实现。唯一的保证是wchar_t至少和char一样大,并且wchar_t和一个内置的整数类型一样大。<cwchar>头为这个内置类型声明了一个 typedef,std::wint_t。在一些实现中,wchar_t可能与char相同,但是大多数桌面和工作站环境使用 16 或 32 位作为wchar_t

挖掘清单 26-2 并修改它,以揭示在您的 C++ 环境中wchar_twint_t的大小。wchar_t中有多少位?_ _ _ _ _ _ _ _ _ _ _ _ _wint_t中有多少?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 他们应该是同一个号码。char中有多少位?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _****

****宽字符串对象使用std::wstring类型(在<string>中声明)。宽字符串是由宽字符组成的字符串。在所有其他方面,宽弦和窄弦的行为类似;它们具有相同的成员函数,并且您以相同的方式使用它们。例如,size()成员函数返回字符串中的字符数,而不管每个字符的大小。

宽字符和字符串文字看起来像它们的窄对等物,除了它们以大写字母L开始并且包含宽字符。在字符或字符串文字中表达宽字符的最佳方式是用\x转义符指定字符的十六进制值(在探索 17 中介绍)。因此,您必须知道您的 C++ 环境使用的宽字符集,并且您必须知道该字符集中所需字符的数值。如果您的编辑器和编译器允许,您可以直接在宽字符文本中编写宽字符,但是您的源代码不能移植到其他环境中。您也可以在宽字符或字符串文字中写入窄字符,编译器会自动将窄字符转换为宽字符,如下所示:

wchar_t capital_a{'A'};        // the compiler automatically widens narrow characters
std::wstring ray{L"Ray"};
wchar_t pi{L'π'};              // if your tools let you type π as a character
wchar_t pi_unicode{L'\x03c0'}; // if wchar_t uses a Unicode encoding, such as UTF-32
std::wstring price{L"\x20ac" L"12345"};           // Unicode Euro symbol: €12345

请注意,在示例的最后一行,我是如何将字符串分成两部分的。回想一下 Exploration 17 中的内容,\x转义开始一个转义序列,该序列通过十六进制(基数为 16)的值来指定一个字符。编译器收集尽可能多的字符来形成一个有效的十六进制数,即数字和字母AF(大写或小写)。然后,它使用该数值作为单个字符的表示。如果最后一行是一个字符串,编译器会试图将整个字符串解释为\x转义。这意味着编译器会认为字符值是十六进制值 20AC12345 16 。通过分隔字符串,编译器知道什么时候\x转义结束,它编译字符值 20AC 16 ,后面是字符12345。就像窄字符串一样,编译器将相邻的宽字符串组装成一个宽字符串。(但是,不允许将窄弦和宽弦放在一起。使用全宽字符串或全窄字符串,而不是两者的混合。)

宽弦

你所知道的关于string的一切也适用于wstring。他们只是一个普通模板的实例,basic_string<string>头声明stringbasic_string<char>的 typedef,而wstringbasic_string<wchar_t>的 typedef。模板的魔力在于关注细节。

因为stringwstring的底层实现实际上是一个模板,任何时候你写一些使用字符串的实用程序代码,你都应该考虑把这些代码也做成一个模板。例如,假设您想要重写is_palindrome函数(来自清单 22-5 ),以便它可以处理宽字符。与其把char换成wchar_t,不如把它变成一个函数模板。首先将支持函数重写为函数模板,将字符类型作为模板参数。重写 is_palindrome 的支持函数,使其适用于窄和宽的字符串和字符。清单 59-1 给出了我的解决方案。

import <locale>;

template<class Char>
auto const& ctype{ std::use_facet<std::ctype<Char>>(std::locale()) };

/** Test for non-letter.
 * @param ch the character to test
 * @return true if @p ch is not a letter
 */
template<class Char>
bool isletter(Char ch)
{
  return ctype<Char>.is(std::ctype_base::alpha, ch);
}

/** Convert to lowercase.
 * @param ch the character to test
 * @return the character converted to lowercase
 */
template<class Char>
Char lowercase(Char ch)
{
  return ctype<Char>.tolower(ch);
}

/** Compare two characters without regard to case. */
template<class Char>
bool same_char(Char a, Char b)
{
  return lowercase(a) == lowercase(b);
}

Listing 59-1.Supporting Cast for the is_palindrome Function Template

下一个任务是重写is_palindrome本身。模板实际上有三个模板参数,basic_string_view有两个。第一个是人物类型,接下来的两个是我们此时不必关心的细节。重要的是,如果您想模板化您自己的处理字符串的函数,您应该处理所有三个模板参数。

然而,在开始之前,在将函数作为标准算法的参数时,您必须意识到一个小障碍:参数必须是真实的函数,而不是函数模板的名称。换句话说,如果你必须使用函数模板,比如lowercasenon_letter,你必须实例化模板并传递模板实例。当您将non_lettersame_char传递给remove_ifequal算法时,一定要传递正确的模板参数。如果Char是字符类型的模板参数,使用non_letter<Char>作为remove_if的仿函数参数。

is_palindrome 函数重写为带有两个模板参数的函数模板。第一个模板参数是字符类型:称之为Char。调用第二个模板参数Traits。您必须对std::basic_string_view模板使用这两个参数。清单 59-2 展示了我版本的is_palindrome函数,它被转换成一个模板,因此它可以处理窄和宽的字符串。

import <ranges>;
import <string_view>;

/** Determine whether @p str is a palindrome.
 * Only letter characters are tested. Spaces and punctuation don't count.
 * Empty strings are not palindromes because that's just too easy.
 * @param str the string to test
 * @return true if @p str is the same forward and backward
 */
template<class Char, class Traits>
bool is_palindrome(std::basic_string_view<Char, Traits> str)
{
  auto letters_only{ str | std::views::filter(isletter<Char>) };
  auto reversed{ letters_only | std::ranges::views::reverse };
  return std::equal(
    std::ranges::begin(letters_only), std::ranges::end(letters_only),
    std::ranges::begin(reversed),     std::ranges::end(reversed),
    same_char<Char>);
}

Listing 59-2.Changing is_palindrome to a Function Template

除了传递给basic_string_view之外,is_palindrome函数从不使用Traits模板参数。如果您对该参数感到好奇,请查阅语言参考资料,但要注意它有点高级。

调用is_palindrome很容易,因为编译器使用自动类型推断来确定您使用的是窄字符串还是宽字符串,并相应地实例化模板。因此,调用者根本不必为模板费心。

不再赘述,isletterlowercase函数模板可以处理宽字符参数。这是因为区域设置是模板,在字符类型上参数化,就像字符串和 I/O 类模板一样。

然而,为了使用宽字符,您必须使用宽字符执行 I/O,这是下一节的主题。

宽字符输入输出

通过从std::wcin开始读取,您可以从标准输入中读取宽字符。通过写入std::wcoutstd::wcerr来写入宽字符。一旦您从流中读取或写入任何内容,相应的窄流和宽流的字符宽度是固定的,并且您不能更改它—您必须决定是使用窄字符还是宽字符,并在流的生存期内保持该选择。所以,一个程序必须使用cinwcin,但不能两者都用。输出流也是如此。<iostream>头声明了所有标准流的名称,窄流和宽流。<istream>头定义了所有的输入流类和操作符;<ostream>定义输出类和操作符。更准确的说,<istream><ostream>定义模板,字符类型是第一个模板参数。

<istream>头定义了std::basic_istream类模板,在字符类型上参数化。同一个头声明了两个 typedefs,如下所示:

using istream = basic_istream<char>;
using wistream = basic_istream<wchar_t>;

正如您所猜测的,<ostream>头是相似的,定义了basic_ostream类模板和ostreamwostream类型定义。

<fstream>头遵循相同的模式— basic_ifstreambasic_ofstream是类模板,带有类型定义,如下所示:

using ifstream  = basic_ifstream<char>;
using wifstream = basic_ifstream<wchar_t>;
using ofstream  = basic_ofstream<char>;
using wofstream = basic_ofstream<wchar_t>;

从清单 22-5 重写主程序,测试带有宽字符 I/Ois_palindrome 函数模板。现代的桌面环境应该能够支持宽字符,但是您可能必须学习一些新的特性,以弄清楚如何让您的文本编辑器保存具有宽字符的文件。您可能还需要加载一些额外的字体。最有可能的情况是,您可以提供一个普通的窄文本文件作为输入,程序就会运行良好。如果你很难找到一个合适的输入文件,尝试一下回文文件,你可以下载本书中的其他例子。文件名表示字符集。例如,palindrome-utf8.txt包含 UTF-8 输入。当读取一个宽流时,你必须确定你的 C++ 环境期望什么格式,并选择正确的文件。我的解决方案如清单 59-3 所示。

int main()
{
  std::locale::global(std::locale{""});
  std::wcin.imbue(std::locale{});
  std::wcout.imbue(std::locale{});

  std::wstring line{};
  while (std::getline(std::wcin, line))
    if (is_palindrome(std::wstring_view{line}))
      std::wcout << line << L'\n';
}

Listing 59-3.The main Program for Testing is_palindrome

从文件中读取宽字符或将宽字符写入文件不同于读取或写入窄字符。所有文件 I/O 都要经过一个额外的字符转换步骤。C++ 总是将文件解释为一系列字节。当读取或写入窄字符时,将字节转换为窄字符是不可行的,但是当读取或写入宽字符时,C++ 库必须解释字节以形成宽字符。它通过累加一个或多个相邻的字节来形成每个宽字符。决定哪些字节是宽字符的元素以及如何组合字符的规则由多字节字符集的编码规则指定。

多字节字符集

多字节字符集起源于亚洲,那里对字符的需求超过了单字节字符集(如 ASCII)中可用的少数字符位。欧洲国家设法将他们的字母表放入 8 位字符集中,但是像中文、日文、韩文和越南文这样的语言需要更多的位来表示成千上万的表意文字、音节和原生字符。

亚洲语言的需求刺激了使用两个字节来编码一个字符的字符集的发展——因此有了通用术语双字节字符集 (DBCS),并概括为多字节字符集 (MBCS)。发明了许多 DBCSes,有时一个字符有多种编码。例如,在中文 Big 5 中,表意文字丁具有双字节值"\xA4\x42"。在 EUC-韩国字符集(在韩国很流行)中,相同的表意文字有不同的编码:"\xEF\xCB"

典型的 DBCS 使用设置了最高有效位的字符(在一个 8 位字节中)来表示双字符。最高有效位清零的字符将取自单字节字符集(SBCS)。一些 DBC 委托特定的 SBCS;其他人则保持开放,因此 DBCS 和 SBCS 的不同组合会有不同的约定。在单个字符流中混合单字节和双字节字符对于表示混合亚洲和西方文本的字符流的常见用法是必要的。使用多字节字符比使用单字节字符更困难。例如,字符串的size()函数不会告诉你一个字符串中有多少个字符。您必须检查字符串的每个字节,以了解字符的数量。对字符串进行索引更加困难,因为您必须小心不要索引到双字节字符的中间。

有时,单个字符流比简单地在一个特定的 SBCS 和一个特定的 DBCS 之间切换需要更多的灵活性。有时流必须混合多个双字节字符集。ISO 2022 标准就是允许在其他辅助字符集之间转换的字符集的一个例子。移位序列(也称为转义序列,不要与 C++ 反斜杠转义序列混淆)决定使用哪个字符集。例如,ISO 2022-JP 在日本被广泛使用,并允许在 ASCII、JIS X 0201(SBCS)和 JIS X 0208(DBCS)之间切换。每行文本以 ASCII 开始,shift 序列在字符串中间改变字符集。例如,换档顺序"\x1B$B"切换到 JIS X 0208-1983。

在包含移位序列的文件或文本流中寻找任意位置显然是有问题的。一个必须在多字节文本流中查找的程序,除了流位置之外,还必须跟踪移位序列。如果不知道流中最近的移位序列,程序就无法知道用哪个字符集来解释后面的字符。

ISO 2022-JP 的许多变体允许附加的字符集。这里的重点不是提供关于亚洲字符集的教程,而是让您了解编写一个真正开放、通用和灵活的机制的复杂性,该机制可以支持世界上丰富多样的字符集和语言环境。这些以及类似的问题导致了 Unicode 项目的出现。

统一码

Unicode 试图通过将所有主要变体统一到一个大的、快乐的字符集来摆脱整个字符集的混乱。在很大程度上,Unicode 联合会取得了成功。Unicode 字符集已被采纳为 ISO 10646 的国际标准。然而,Unicode 项目不仅仅包括字符集;它还指定了大小写折叠、字符排序等规则。

Unicode 提供了 1114112 个可能的字符值(称为代码点)。到目前为止,Unicode Consortium 已经为字符分配了大约 100,000 个码位,因此还有很大的扩展空间。表示一百万个码位的最简单方法是使用 32 位整数,事实上,这是 Unicode 的一种常见编码。然而,这不是唯一的编码。Unicode 标准还定义了允许您使用一个或两个 16 位整数和一个或四个 8 位整数来表示代码点的编码。

表示 Unicode 码位的标准方式是 U+,后面跟一个至少有四位的十六进制数。因此,'\x41'是 U+0041(拉丁文大写 A )的 C++ 编码,希腊文π的码位是 U+03C0。音乐的八分音符具有代码点 U+266A 或 U+1d 160;前一个码位在一组杂七杂八的符号里,刚好包括一个八分音符。后一个代码点是一组音乐符号的一部分,您将需要它来处理任何与音乐相关的字符。

UTF-32 是将码位存储为 32 位整数的编码名称。要在 C++ 中表示 UTF-32 码位,在字符前面加上U(大写字母 U )。这样的字符文字有类型char32_t。例如,要表示字母 A ,用U'A';对于小写希腊π,用U'\x03c0';对于音乐的八分音符,使用U'\x266a'U'\x1d160'。对字符串文字做同样的操作,标准库为一个字符串char32_t定义了类型std::u32string。例如,要表示字符π ≈ 3.14,请使用以下公式:

std::u32string pi_approx_3_14{ U"\x03c0 \x2248 3.14" };

另一种常见的 Unicode 编码使用一到四个 8 位单元组成一个码位。西欧语言中的常见字符通常可以用一个字节表示,其他许多字符只需要两个字节。不常用的字符需要三个或四个。结果是一种支持所有 Unicode 码位的编码,并且几乎总是比其他编码消耗更少的内存。这个字符集被称为 UTF-8。UTF-8 字符以普通字符文字的方式书写,以u8开头。UTF 8 字符串文字的类型是char8_t。一架 UTF-8 弦有std::u8string

表示一个希腊字母π只需要两个字节,但是与 UTF-32 中的两个低位字节的值不同:u8"\xcf\x80"。第八个音符需要三或四个字节,同样使用不同于 UTF-32 的编码:u8"\ xe2\ x99\xaa"u8"\xf0\x9d\x85\xa0"

在程序中处理 UTF-8 的主要困难是,知道一个字符串中有多少代码点的唯一方法是扫描整个字符串。size()成员函数返回字符串中存储单元的数量,但是每个码位需要一到四个存储单元。另一方面,UTF-8 的优点是,您可以在 UTF-8 字节流中查找任意位置,并知道该位置是否在多字节字符的中间,因为多字节字符总是有其最高有效位集。通过检查编码,您可以判断一个字节是多字节字符的第一个字节还是后面的字节。

UTF-8 是文件和网络传输的常用编码。它已经成为许多桌面环境、文字处理器(包括我用来写这本书的那个)、网页和其他日常应用的事实上的标准。

其他一些环境使用 UTF-16,它用一个或两个 16 位整数表示一个码位。UTF-16 字符文本的 C++ 类型是char16_t,字符串类型是std::u16string。用u前缀(小写字母 u )写出这样一个字面值,比如u'\x03c0'

Unicode 的设计者将最常见的代码点保留在较低的 16 位区域(称为基本多语言平面,或 BMP)。当一个码位在 BMP 之外,也就是它的值超过 U+FFFF 时,它需要两个 UTF-16 的存储单元,被称为代理对。例如,丁需要两个 16 位存储单元:u"\ xD834\ xDD1E"

因此,您会遇到与 UTF-8 相同的问题,即一个存储单元不一定代表一个代码点,因此 UTF-16 作为内存中的表示法并不理想。但是大多数程序处理的绝大多数代码点都可以放在一个 UTF-16 存储单元中,所以 UTF-16 通常需要的内存是 UTF-32 的一半,而且在很多情况下,一个u16stringsize()就是字符串中代码点的数量(虽然不扫描字符串你无法确定)。

一些程序员通过完全忽略代理对来解决使用 UTF-16 的困难。他们假设size()确实返回了字符串中代码点的数量,所以只有当所有代码点都来自 BMP 时,他们的程序才能正常工作。这意味着你失去了访问古代文字,专门的字母和符号,以及不常用的表意文字。

对于外部表示,UTF 8 比 UTF-16 和 UTF-32 编码有优势,因为您不必处理字节序。Unicode 标准定义了一种机制,用于编码和显示 UTF-16 或 UTF-32 文本流的字节序,但这只是为您做了额外的工作。

Note

最高有效字节的位置称为“字节序”“大端”平台是最高有效字节优先的平台。“小端”平台将最低有效字节放在最前面。流行的英特尔 x86 平台是小端的。

通用字符名称

Unicode 在 C++ 标准中又一次正式出现。您可以使用字符的 Unicode 码位来指定字符。使用\uXXXX\UXXXXXXXX,用十六进制码位替换XXXXXXXXXXXX。与\x转义不同,您必须在\u中使用四个十六进制数字,或者在\U中使用八个十六进制数字。这些字符结构被称为通用字符名

因此,在字符串中对国际字符进行编码的更好方法是使用通用字符名。这有助于使你免受本地字符集的影响。另一方面,如果编译器无法将 Unicode 码位映射到本机字符,您就无法控制编译器的操作。因此,如果您的原生字符集是 ISO 8859-7(希腊语),下面的代码应该用值'\xf0'初始化变量pi,但是如果您的原生字符集是 ISO 8859-1 (Latin-1),编译器不能映射它,因此可能会给您一个空格或问号,或者编译器可能会拒绝编译它:

char pi{'\u03c0'};

还要注意\u\U不是转义序列(不像\x)。您可以在程序中的任何地方使用它们,而不仅仅是在字符或字符串中。使用 Unicode 字符名称可以让您在不知道编码细节的情况下使用 UTF-8 和 UTF-16 字符串。因此,对于希腊文小写π,更好的 UTF-8 字符串写法是u8"\ u03c0",编译器会存储编码后的字节"\xcf\x80"

如果你幸运的话,你将能够避免通用的字符名字。相反,您的工具将允许您直接编辑 Unicode 字符。该编辑器只是读取和写入通用字符名,而不是处理 Unicode 编码问题。因此,程序员编辑 WYSIWYG 国际文本,源代码保留了最大的可移植性。因为任何地方都允许通用字符名称,所以您也可以在注释中使用国际文本。如果你真的想玩得开心,试着在标识符名称中使用国际字母。不是所有的编译器都支持这个特性,尽管标准要求如此。因此,您应该编写一个声明

double π{3.14159265358979};

您的智能编辑器会在源文件中存储以下内容:

double \u03c0{3.14159265358979};

符合标准的编译器会接受它,并允许您使用π作为标识符。我不建议在标识符中使用扩展字符,除非你知道每个阅读你代码的人都在使用能够识别通用字符名的工具。否则,它们会使代码更难阅读、理解和维护。

你的编译器支持字符串中的通用字符名吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 你的编译器支持标识符中的通用字符名吗? _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

Unicode 困难

尽管 Unicode 有表面上的好处,但 C++ 支持仍然很少。虽然您可以编写 Unicode 字符文本和字符串文本,但是标准库没有提供有用的支持。试试这个练习:修改回文程序,用char32_t代替wchar_t会发生什么?


没用的。Unicode 没有 I/O 流类。对于char8_tchar16_tchar32_t,不存在isalnum等的模板特化。尽管标准库提供了一些在 Unicode 字符串和wstring之间进行转换的函数,但支持仅限于此。

如果你必须以任何有意义的方式使用国际字符,你需要一个第三方库。使用最广泛的库是 Unicode 国际组件(ICU)。请访问该书的网站获取最新链接。

第三部分的下一个也是最后一个主题是加深您对文本 I/O 的理解。****

六十、文本输入/输出

输入和输出有两种基本风格:文本和二进制。二进制 I/O 引入的微妙之处超出了本书的范围,因此本文中所有关于 I/O 的讨论都是面向文本的。这一探索展示了与文本 I/O 相关的各种主题。您已经看到了输入和输出操作符如何处理内置类型以及标准库类型(如果有意义的话)。您还看到了如何为自定义类型编写自己的 I/O 操作符。这一探索提供了一些关于文件模式、读取和写入字符串以及将值与字符串相互转换的更多细节。

文件模式

探索 14 简单介绍了文件流类ifstreamofstream。基本行为是获取一个文件名并打开它。通过传递第二个参数,即文件模式,您可以获得更多的控制权。ifstream的默认模式是std::ios_base::in,打开文件进行输入。ofstream的默认模式是std::ios_base::out | std::ios_base::trunc。(|运算符组合某些值,如模式。探索 68 将对此进行深入探讨。)模式out打开文件进行输出。如果文件不存在,则创建该文件。trunc模式意味着截断文件,因此您总是从一个空文件开始。如果您明确指定模式并省略trunc,旧内容(如果有)将保留。因此,默认情况下,写入输出流会覆盖旧内容。如果要将流定位在旧内容的末尾,使用ate模式(end 处的简称),将流的初始位置设置为现有文件内容的末尾。默认情况下,将流放在文件的开头。

另一种有用的输出模式是app(是 append 的缩写),它使得每次写入都追加到文件中。也就是说,app影响每次写入,而ate只影响起始位置。在写入日志文件时,app模式非常有用。

编写一个 debug() **函数,该函数将一个字符串作为参数,并将该字符串写入一个名为“debug . txt”**的文件中。清单 60-1 显示了接口模块。

export module debug;
import <string_view>;

/** @brief Write a debug message to the file @c "debug.txt"
 * @param msg The message to write
 */
export void debug(std::string_view msg);

Listing 60-1.Module That Declares a Trivial Debugging Function

将每条日志消息附加到文件中,用换行符结束每条消息。为了确保正确记录调试信息,即使程序崩溃,也要在每次调用debug()函数时重新打开文件。清单 60-2 展示了我的实现模块。

module debug;
import <fstream>;
import <ostream>;
import <stdexcept>;

void debug(std::string_view str)
{
   std::ofstream stream{"debug.txt", std::ios_base::out | std::ios_base::app};
   if (not stream)
      throw std::runtime_error("cannot open debug.txt");
   stream.exceptions(std::ios_base::failbit);
   stream << str << '\n';
   stream.close();
}

Listing 60-2.Implementing the Debug Function

字符串流

除了文件流,C++ 还提供了字符串流。<sstream>头定义了istringstreamostringstream。一个字符串流读写一个std::string对象。对于输入,将字符串作为参数提供给istringstream构造器。对于输出,您可以提供一个字符串对象,但更常见的用法是让流为您创建和管理字符串。流追加到字符串,允许字符串根据需要增长。写完流之后,调用str()成员函数来检索最终的字符串。

假设您必须从一个文件中读取代表汽车里程表读数和加满油箱所需燃油量的成对数字。该程序计算每加仑的英里数(或者每公里的升数,如果你喜欢的话)。文件格式很简单:每行都有里程表读数,后跟燃油量,在一行上,用空格隔开。

写程序。清单 60-3 展示了每加仑英里的方法。

import <iostream>;

int main()
{
   double total_fuel{0.0};
   double total_distance{0.0};
   double prev_odometer{0.0};
   double fuel{}, odometer{};
   while (std::cin >> odometer >> fuel)
   {
      if (fuel != 0)
      {
         double distance{odometer - prev_odometer};
         std::cout << distance / fuel << '\n';
         total_fuel += fuel;
         total_distance += distance;
         prev_odometer = odometer;
      }
   }
   if (total_fuel != 0)
      std::cout << "Net MPG=" << total_distance / total_fuel << '\n';
}

Listing 60-3.Computing Miles per Gallon

清单 60-4 显示了相同的程序,但是计算的是升每公里。在接下来的探索中,我将使用英里每加仑。不使用这种方法的读者可以查阅《百升每公里》一书的随机文件。

import <iostream>;

int main()
{
   double total_fuel{0.0};
   double total_distance{0.0};
   double prev_odometer{0.0};
   double fuel{}, odometer{};
   while (std::cin >> odometer >> fuel)
   {
      fuel *= 100.0;
      double distance{odometer - prev_odometer};
      if (distance != 0)
      {
         std::cout << fuel / distance << '\n';
         total_fuel += fuel;
         total_distance += distance;
         prev_odometer = odometer;
      }
   }
   if (total_distance != 0)
      std::cout << "Net 100LPK=" << total_fuel / total_distance << '\n';
}

Listing 60-4.Computing Liters per Kilometer

如果用户不小心 忘记在文件的某一行记录燃料会怎样?


输入循环不知道也不关心行。在寻求满足每个输入请求时,它坚决跳过空白。因此,它读取后续线路的里程表读数作为燃油量。结果自然会不正确。

更好的解决方案是将每一行作为一个字符串读取,并从字符串中提取两个数字。如果字符串格式不正确,则发出一条错误消息并忽略该行。您通过调用std::getline函数(在<string>中声明)将一行文本读入到std::string中。这个函数将一个输入流作为第一个参数,将一个string对象作为第二个参数。它返回流,这意味着如果读取成功,它返回一个真值,如果读取失败,它返回一个假值,所以您可以使用对getline的调用作为循环条件。

一旦有了字符串,打开一个istringstream来读取字符串。像使用任何其他输入流一样使用字符串流。从字符串流中读取两个数字。如果字符串流不包含任何数字,则忽略该行。如果它只包含一个数字,则发出适当的错误消息。清单 60-5 呈现了新的程序。

import <iostream>;
import <sstream>;
import <string>;

int main()
{
   double prev_odometer{0.0};
   double total_fuel{0.0};
   double total_distance{0.0};
   std::string line{};
   int linenum{0};
   bool error{false};
   while (std::getline(std::cin, line))
   {
      ++linenum;
      std::istringstream input{line};
      double odometer{};
      if (input >> odometer)
      {
         double fuel{};
         if (not (input >> fuel))
         {
            std::cerr << "Missing fuel consumption on line " << linenum << '\n';
            error = true;
         }
         else if (fuel != 0)
         {
            double distance{odometer - prev_odometer};
            std::cout << distance / fuel << '\n';
            total_fuel += fuel;
            total_distance += distance;
            prev_odometer = odometer;
         }
      }
   }
   if (total_fuel != 0)
   {
      std::cout << "Net MPG=" << total_distance / total_fuel;
      if (error)
         std::cout << " (estimated, due to input error)";
      std::cout << '\n';
   }
}

Listing 60-5.Rewriting the Miles-per-Gallon Program to Parse a String Stream

大多数文本文件格式都允许某种形式的注释或评论。文件格式已经允许一种形式的注释,作为程序实现的副作用。如何给输入文件添加注释?


在程序从一行中读取燃油量后,它会忽略字符串的其余部分。您可以在任何包含正确里程表和燃油数据的行中添加注释。但那是草率的副作用。更好的设计要求用户插入一个明确的注释标记。否则,程序可能会将错误的输入误解为有效的输入,后面跟着一个注释,例如意外插入了一个额外的空格,如下所示:

123  21 10.23

让我们修改文件格式。任何以井号(#)开头的行都是注释。在读取注释字符时,程序跳过整行。将此功能添加到程序中。一个有用的函数是输入流的unget()函数。从流中读取一个字符后,unget()将该字符返回到流中,使后续的读取操作再次读取该字符。换句话说,读完一行,从该行中读出一个字符,如果是'#',则跳过该行。否则,调用unget()并像以前一样继续。将您的结果与我的进行比较,如清单 60-6 所示。

import <iostream>;
import <sstream>;
import <string>;

int main()
{
   double total_fuel{0.0};
   double total_distance{0.0};
   double prev_odometer{0.0};
   std::string line{};
   int linenum{0};
   bool error{false};
   while (std::getline(std::cin, line))
   {
      ++linenum;
      std::istringstream input{line};
      char comment{};
      if (input >> comment and comment != '#')
      {
         input.unget();
         double odometer{};
         if (input >> odometer)
         {
           double fuel{};
            if (not (input >> fuel))
            {
               std::cerr << "Missing fuel consumption on line " << linenum << '\n';
               error = true;
            }
            else if (fuel != 0)
            {
               double distance{odometer - prev_odometer};
               std::cout << distance / fuel << '\n';
               total_fuel += fuel;
               total_distance += distance;
               prev_odometer = odometer;
            }
         }
      }
   }
   if (total_fuel != 0)
   {
      std::cout << "Net MPG=" << total_distance / total_fuel;
      if (error)
         std::cout << " (estimated, due to input error)";
      std::cout << '\n';
   }
}

Listing 60-6.Parsing Comments in the Miles-per-Gallon Data File

更复杂的是允许注释标记出现在一行的任何地方。注释从#字符延伸到行尾。注释标记可以出现在一行的任何位置,但是如果该行包含任何数据,则它必须在注释标记之前包含两个有效数字。增强程序以允许注释标记出现在任何地方。考虑使用std::stringfind()成员函数。它有多种形式,其中一种以字符作为参数,返回该字符在字符串中第一次出现的从零开始的索引。返回类型为std::string::size_type。如果字符不在字符串中,find()返回神奇常量std::string::npos

一旦找到注释标记,就可以通过调用erase()删除注释,或者通过调用substr()复制字符串的非注释部分。字符串成员函数使用从零开始的索引。子字符串表示为起始位置和受影响的字符数。通常,count 可以省略,表示字符串的其余部分。将你的解决方案与我的进行比较,如清单 60-7 所示。

import <iostream>;
import <sstream>;
import <string>;

int main()
{
   double total_fuel{0.0};
   double total_distance{0.0};
   double prev_odometer{0.0};
   std::string line{};
   int linenum{0};
   bool error{false};
   while (std::getline(std::cin, line))
   {
      ++linenum;
      std::string::size_type comment{line.find('#')};
      if (comment != std::string::npos)
         line.erase(comment);
      std::istringstream input{line};
      double odometer{};
      if (input >> odometer)
      {
         double fuel{};
         if (not (input >> fuel))
         {
            std::cerr << "Missing fuel consumption on line " << linenum << '\n';
            error = true;
         }
         else if (fuel != 0)
         {
            double distance{odometer - prev_odometer};
            std::cout << distance / fuel << '\n';
            total_fuel += fuel;
            total_distance += distance;
            prev_odometer = odometer;
         }
      }
   }
   if (total_fuel != 0)
   {
      std::cout << "Net MPG=" << total_distance / total_fuel;
      if (error)
         std::cout << " (estimated, due to input error)";
      std::cout << '\n';
   }
}

Listing 60-7.Allowing Comments Anywhere in the Miles-per-Gallon Data File

现在文件格式允许在每行上显式注释,您应该添加更多的错误检查,以确保每行只包含两个数字,仅此而已(删除注释后)。检查的一种方法是在读取两个数字后读取单个字符。如果读取成功,则该行包含错误的文本。添加错误检查以检测带有额外文本的行。将您的解决方案与我的解决方案进行比较,如清单 60-8 所示。

import <iostream>;
import <sstream>;
import <string>;

int main()
{
   double total_fuel{0.0};
   double total_distance{0.0};
   double prev_odometer{0.0};
   std::string line{};
   int linenum{0};
   bool error{false};
   while (std::getline(std::cin, line))
   {
      ++linenum;
      std::string::size_type comment{line.find('#')};
      if (comment != std::string::npos)
         line.erase(comment);
      std::istringstream input{line};
      double odometer{};
      if (input >> odometer)
      {
         double fuel{};
         char check{};
         if (not (input >> fuel))
         {
            std::cerr << "Missing fuel consumption on line " << linenum << '\n';
            error = true;
         }
         else if (input >> check)

         {
            std::cerr << "Extra text on line " << linenum << '\n';
            error = true;
         }
         else if (fuel != 0)
         {
            double distance{odometer - prev_odometer};
            std::cout << distance / fuel << '\n';
            total_fuel += fuel;
            total_distance += distance;
            prev_odometer = odometer;
         }
      }
   }
   if (total_fuel != 0)
   {
      std::cout << "Net MPG=" << total_distance / total_fuel;
      if (error)
         std::cout << " (estimated, due to input error)";
      std::cout << '\n';
   }
}

Listing 60-8.Adding Error-Checking for Each Line of Input

文本转换

让我戴上透视帽一会儿。看得出来你对 C++ 有很多未解的疑问;其中一个问题是“我怎样才能轻松地将一个数字转换成一个字符串,反之亦然呢?”

标准库提供了一些简单的函数:std::to_string()接受一个整数并返回一个字符串表示。要将一个字符串转换成一个整数,可以根据所需的返回类型从几个函数中进行选择:std::stoi()返回一个int,而std::stod()返回double

但是这些功能没有什么灵活性。您知道 I/O 流提供了很多灵活性和对格式的控制。您肯定会说,您可以创建使用合适的缺省值也同样容易使用的函数,而且在格式方面也提供了一些灵活性(比如浮点精度、填充字符等)。).

既然您已经知道了如何使用字符串流,接下来的路就很清楚了:使用istringstream从字符串中读取数字,或者使用ostringstream将数字写入字符串。唯一的任务是将功能包装在适当的函数中。更好的是使用模板。毕竟,读或写一个int和读或写一个long和其他的本质上是一样的。

清单 60-9 显示了from_string函数模板,它只有一个模板参数T——要转换的对象类型。该函数返回类型T,并接受一个函数参数:一个要转换的字符串。

import <istream>;
import <sstream>;
import <stdexcept>;
import <string>;

template<class T>
requires
  requires(T value, std::istream stream) {
    stream >> value;
  }
T from_string(std::string const& str)
{
  std::istringstream in{str};
  T result{};
  if (in >> result)
    return result;
  else
    throw std::runtime_error{str};
}

Listing 60-9.The from_string Function Extracts a Value from a String

T可以是允许使用>>操作符从输入流中读取的任何类型,包括您编写的任何自定义操作符和类型。

那么宣传的灵活性呢?再补充一些吧。如上所述,from_string函数不检查值后面的文本。另外,它跳过了前导空白。修改函数取一个 bool 自变量: skipws。如果为真,from_string跳过前导空白并允许尾随空白。如果为 false,则不跳过前导空白,也不允许尾随空白。在这两种情况下,如果无效文本跟在值后面,它抛出runtime_error。在清单 60-10 中将你的解决方案与我的进行比较。

import <istream>;
import <sstream>;
import <stdexcept>;
import <string>;

template<class T>
requires
  requires(T value, std::istream stream) {
    stream >> value;
  }
T from_string(std::string const& str, bool skipws = true)
{
  std::istringstream in{str};
  if (not skipws)
    in >> std::noskipws;
  T result{};
  char extra;
  if (not (in >> result))
    throw std::runtime_error{str};
  else if (in >> extra)
    throw std::runtime_error{str};
  else
    return result;
}

Listing 60-10.Enhancing the from_string Function

我加入了一个新的语言功能。函数参数skipws后面是= true,看起来像是赋值或者初始化。它让你用一个参数调用from_string,就像以前一样,用true作为第二个参数。如果你想知道,这就是文件流如何指定默认文件模式的。如果您决定声明默认参数值,则必须从参数列表中最右边的参数开始提供它们。我不经常使用默认参数,在探索 73 中,你会学到一些与默认参数和重载相关的微妙之处。现在,当它们有用的时候使用它们,但是要谨慎使用。

轮到你从头开始写一个函数了。编写 to_string 函数模板,它采用单个模板参数,并声明to_string函数采用该类型的单个函数参数。该函数通过将其参数写入字符串流来将其转换为字符串,并返回结果字符串。将你的解决方案与我的进行比较,如清单 60-11 所示。

import <ostream>;
import <sstream>;
import <string>;

template<class T>
requires
  requires(T value, std::ostream stream) {
    stream << value;
  }
std::string to_string(T const& obj)
{
  std::ostringstream out{};
  out << obj;
  return out.str();
}

Listing 60-11.The to_string Function Converts a Value to a String

你能看出这些功能有什么特别的缺点吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _如果有,是什么?


毫无疑问,你可以看到许多问题,但特别是,我想指出的是,它们不适用于宽字符。浪费您花在理解宽字符上的所有精力是很可惜的,所以让我们为字符类型添加另一个模板参数。std::string类模板有三个模板参数:字符类型、字符特征和一个管理字符串可能使用的堆内存的分配器对象。你不必知道这三种类型的任何细节;您只需要将它们传递给basic_string类模板。basic_ostringstream类模板采用前两个模板参数。

您第一次尝试实现to_string可能看起来有点像清单 60-12 。

import <ostream>;
import <sstream>;
import <string>;

template<class T, class Char, class Traits, class Allocator>
requires
  requires(T value, std::ostream stream) {
    stream << value;
  }
std::basic_string<Char, Traits, Allocator> to_string(T const& obj)
{
  std::basic_ostringstream<Char, Traits> out{};
  out << obj;
  return out.str();
}

Listing 60-12.Rewriting to_string As a Template Function

这种实现是可行的。没错,但是很笨拙。试试看。试着写一个简单的测试程序,将一个整数转换成窄字符串,将同一个整数转换成宽字符串。如果你想不出怎么做,也不要气馁。这个练习演示了如果不小心的话,标准库中的模板是如何把你引入歧途的。看看我在清单 60-13 中的解决方案。

import <iostream>;
import to_string;
import from_string;

int main()
{
    std::string str{
      to_string<int, char, std::char_traits<char>, std::allocator<char>>(42)
    };
    int value{from_string<int>(str)};
    std::cout << value << '\n';
}

Listing 60-13.Demonstrating the Use of to_string

你明白我的意思吗?您怎么知道第三和第四个模板参数是什么呢?别担心,我们能找到更好的解决办法。

另一种方法是不返回字符串,而是将它作为输出函数参数。然后编译器可以使用参数类型的演绎,你不必指定所有的模板参数。编写一个版本的 to_string ,它采用相同的模板参数,但也有两个函数参数:要转换的值和目标字符串。编写一个演示程序来展示这个函数使用起来有多简单。清单 60-14 展示了我的解决方案。

import <iostream>;
import <sstream>;
import <string>;
import from_string;

template<class T, class Char, class Traits, class Allocator>
requires
  requires(T value, std::ostream stream) {
    stream << value;
  }
void to_string(T const& obj, std::basic_string<Char, Traits, Allocator>& result)
{
  std::basic_ostringstream<Char, Traits> out{};
  out << obj;
  result = out.str();
}

int main()
{
    std::string str{};
    to_string(42, str);
    int value(from_string<int>(str));
    std::cout << value << '\n';
}

Listing 60-14.Passing the Destination String As an Argument to to_string

另一方面,如果你想在表达式中使用字符串,你仍然需要声明一个临时变量来保存字符串。

解决这个问题的另一种方法是将std::stringstd::wstring指定为唯一的模板参数。编译器可以推断出要转换的对象的类型。basic_string模板为其模板参数提供了成员类型,因此您可以使用它们来发现特征和分配器类型。to_string函数返回字符串类型,并接受对象类型的参数。这两种类型都必须是模板参数。应该首先选择哪个参数?清单 60-15 展示了to_string的最新版本,它现在接受两个模板参数:字符串类型和对象类型。

import <ostream>;
import <sstream>;

template<class String, class T>
requires
  requires(T value, std::ostream stream) {
    stream << value;
    typename String::value_type;
    typename String::traits_type;
  }
String to_string(T const& obj)
{
  std::basic_ostringstream<typename String::value_type,
                           typename String::traits_type> out{};
  out << obj;
  return out.str();
}

Listing 60-15.Improving the Calling Interface of to_string

还记得探险 57 里的typename吗?编译器不知道String::value_type命名了一个类型。一个basic_ostringstream的特殊化可以声明它是任何东西。关键字typename告诉编译器你知道这个名字是用于一个类型的。称这种形式为to_string是直截了当的。

to_string<std::string>(42);

这种形式似乎在灵活性和易用性之间取得了最佳平衡。但是我们能增加更多的格式灵活性吗?我们应该增加宽度和填充字符吗?场调?十六进制还是八进制?如果to_stringstd::ios_base::fmtflags为参数,调用者可以指定任何格式标志怎么办?默认应该是什么?清单 60-16 显示了当作者走极端时会发生什么。

import <iostream>;
import <sstream>;

template<class String, class T>
requires
  requires(T value, std::ostream stream) {
    stream << value;
    typename String::value_type;
    typename String::traits_type;
  }
String to_string(T const& obj,
  std::ios_base::fmtflags flags = std::ios_base::fmtflags{},
  int width = 0,
  char fill = ' ')
{
  std::basic_ostringstream<typename String::value_type,
                           typename String::traits_type> out{};
  out.flags(flags);
  out.width(width);
  out.fill(fill);
  out << obj;
  return out.str();
}

Listing 60-16.Making to_string Too Complicated

清单 60-17 展示了一些调用这种形式的to_string的例子。

import <iostream>;
import <string>;
import to_string;

int main()
{
  std::cout << to_string<std::string>(42, std::ios_base::hex) << '\n';
  std::cout << to_string<std::string>(42.0, std::ios_base::scientific, 10) << '\n';
  std::cout << to_string<std::string>(true, std::ios_base::boolalpha) << '\n';
}

Listing 60-17.Calling the Complicated Version of to_string

您应该会看到以下输出:

2a
4.200000e+01
true

这个版本的to_string有太多的参数,不容易使用,而且它太冗长,无法实现任何类型的格式化。标准库为<format>模块提供了一种完全不同的方法。您将需要一个语言参考来获得完整的解释,但基本思想是编写一个描述所需格式的格式化字符串,并传递与格式化字符串匹配的参数。格式字符串的工作方式类似于它们在 Python 中的工作方式。您可以传递任意数量的参数,并一次格式化所有参数。清单 60-18 演示了一次对format()函数的调用如何格式化清单 60-17 的所有三个值。

import <format>;
import <iostream>;

int main()
{
  std::cout << std::format("{0:x}\n{1:.10e}\n{2}\n", 42, 42.0, true);
}

Listing 60-18.Calling std::format

to Format a String

将宽字符串作为格式字符串传递,以产生宽字符串结果。也可以用输出迭代器作为第一个参数调用std::format_to(),将格式化字符串写入迭代器,而不是构造一个字符串。这是格式化文本输出的更有效的方法。修改清单 60-17 以写入一个迭代器,该迭代器写入 std::cout ,从而消除对临时字符串的需要。因为format_to()函数完成所有的格式化并生成字符,所以可以用std::ostreambuf_iterator<char>代替ostream_iterator。清单 60-19 展示了我的解决方案。

import <format>;
import <iostream>;
import <iterator>;

int main()
{
  std::format_to(std::ostreambuf_iterator<char>(std::cout),
     "{0:x}\n{1:.10e}\n{2}\n", 42, 42.0, true);
}

Listing 60-19.Calling std::format_to

to Format Output

第三部分到此结束。项目时间到了。

六十一、项目 3:货币类型

是时候进行另一个项目了。您将继续在项目 2 的fixed类型的基础上进行构建,并结合您所学到的关于语言环境和 I/O 的知识。这次您的任务是编写一个currency类型。该值存储为定点值。使用get_moneyput_money操纵器格式化输入/输出。

确保可以将两个currency量相加得到一个currency值,减去两个currency量得到currency,将currency乘以并除以一个整数或rational值得到一个currency结果,将两个currency值相除得到一个rational结果。

与任何项目一样,从小处着手,然后逐步增加功能。例如,从基本数据表示开始,然后添加 I/O 操作符。一次添加一个算术运算符。在实现特性之前,编写每个测试函数。

六十二、指针

很少有话题比指针更容易引起混淆,尤其是对于 C++ 新手来说。指针是必要的、强大的、多才多艺的,但它也可能是危险的,是许多 bug 的潜在原因,它们既是祸根也是福音。指针在标准库的许多特性背后努力工作,任何严肃的应用程序或库都不可避免地以某种方式使用指针。大多数应用程序员不直接使用指针,但是它们以你不能忽视的方式影响着整个 C++ 标准。

编程问题

在深入研究语法和语义之前,先考虑以下问题。现实生活中的 C++ 项目通常包含多个源文件,每个源文件导入多个模块。在工作时,您将多次编译和重新编译项目。每次,最好只重新编译那些已经改变的文件,或者导入一个接口已经改变的模块。不同的开发环境有不同的工具来决定重新编译哪些文件。IDE 通常自己做出这些决定;在其他环境中,一个单独的工具,如makejamscons,检查项目中的文件并决定重新编译哪些文件。(我使用cmake来编译和测试本书中的所有代码清单。)

在这一步和接下来的探索中要解决的问题是编写一个简单的工具来决定编译哪些文件并假装编译它们。(实际上调用外部程序超出了本书的范围,所以你不会学到如何编写一个完整的构建工具。)

基本思想很简单:要制作一个可执行程序,你必须把源文件编译成目标文件,然后把目标文件连接起来形成程序。据说可执行程序依赖于目标文件,而目标文件又依赖于源文件。其他术语将程序作为目标,将目标文件作为其依赖。反过来,一个目标文件也可以是一个目标,它有一个源文件和作为依赖项导入的模块接口文件。

如您所知,要将单个源文件编译成单个目标文件,编译器可能需要读取许多附加的模块文件。这些模块文件中的每一个都是目标文件的依赖项。因此,一个模块文件可以是许多目标文件的依赖项。用更专业的术语来说,目标和依赖关系形成了一个有向无环图(DAG),我称之为依赖图

Note

一个循环图,例如 A 依赖于 B and B 依赖于 A,在现实世界中是一个坏主意,通常表明一个错误的或考虑不周的设计。为了简单起见,我将在本次和后续探索中忽略这一错误条件。

任何参与过大型项目的人都知道依赖图会变得非常复杂。有些模块文件可能是其他程序生成的,所以模块文件是目标,生成程序是依赖,生成程序是目标,有自己的依赖。

ide 和程序,如make,分析依赖图并确定哪些目标必须首先构建,以确保每个目标的依赖关系都得到满足。因此,如果 A 依赖于 B and B 依赖于 C,make必须首先构建 C(如果它是目标),然后是 B,最后是 A。make用来找到构建目标的正确顺序的关键算法是一个拓扑排序

拓扑排序不包括在许多计算机科学专业的典型算法课程中。算法也没有出现在很多教材里。然而,任何综合性的算法书都包括拓扑排序。

Note

关于拓扑排序的一篇好文章是算法简介,第三版。,作者 T. H. Cormen、C. E. Leiserson 和 R. L. Rivest(麻省理工学院出版社,2009 年)。我的解决方案实现了练习 22.4-5。

C++ 标准库不包括拓扑排序算法,因为它不是顺序算法。它在图上操作,C++ 库没有标准的图形类。

我们将通过编写一个伪make程序来开始这一探索——也就是说,一个读取 makefile 的程序:一个描述一组目标及其依赖关系的文件,执行拓扑排序以找到构建目标的顺序,并以正确的构建顺序打印目标。为了在某种程度上简化程序,将输入限制为一个文本文件,该文件将依赖项声明为成对的字符串,一对字符串位于一行文本上。第一个字符串是目标的名称,第二个字符串是依赖项的名称。如果目标有多个依赖项,输入文件必须在多行中列出目标,每个依赖项一行。一个目标可以是另一个目标的依赖项。输入文件中各行的顺序并不重要。我们的目标是编写一个按顺序打印目标的程序,这样一个类似于make的程序可以首先构建第一个目标,然后按顺序进行,这样所有的目标在作为依赖项被需要之前就被构建好了。

为了帮助澄清术语,我使用术语工件来表示可以是目标、依赖或者两者兼有的字符串。如果你已经知道了拓扑排序的算法,那么现在就开始实现这个程序吧。否则,继续阅读查看topological_sort的一个实现。要表示依赖关系图,请使用集合映射。映射键是一个依赖项,值是将该键作为依赖项列出的一组目标。这似乎与你通常考虑的组织目标和依赖的方式完全不同,但是正如你在清单 62-1 中看到的,这使得拓扑排序很容易实现。因为topological_sort函数是可重用的,所以它是一个模板函数,使用节点而不是工件、目标和依赖项。

export module topsort;
import <deque>;
import <ranges>;
import <stdexcept>;

// Helper function for topological_sort().
template<class Graph, class Nodes>
requires
    std::ranges::range<Graph> and
    requires {
        typename Graph::value_type;
        typename Graph::key_type;
    }
void topsort_clean_graph(Graph& graph, Nodes& nodes)
{
  for (auto iter{std::ranges::begin(graph)}; iter != std::ranges::end(graph);)
  {
    if (iter->second.empty())
    {
      nodes.push_back(iter->first);
      graph.erase(iter++);  // advance iterator before erase invalidates it
    }
    else
      ++iter;
  }
}

/// Topological sort of a directed acyclic graph.
/// A graph is a map keyed by nodes, with sets of nodes as values.
/// Edges run from values to keys. The sorted list of nodes
/// is copied to an output iterator in reverse order.
/// @param graph The graph
/// @param sorted The output iterator
/// @throws std::runtime_error if the graph contains a cycle
/// @pre Graph::key_type == Graph::mapped_type::key_type
export template<class Graph, class OutIterator>
requires
    std::ranges::range<Graph> and
    requires {
        typename Graph::value_type;
        typename Graph::key_type;
    }
    and
    std::output_iterator<OutIterator, typename Graph::key_type>
void topological_sort(Graph graph, OutIterator sorted)
{
  std::deque<typename Graph::key_type> nodes{};
  // Start with the set of nodes with no incoming edges.
  topsort_clean_graph(graph, nodes);

  while (not nodes.empty())
  {
    // Grab the first node to process, output it to sorted,
    // and remove it from the graph.
    auto n{nodes.front()};
    nodes.pop_front();
    *sorted = n;
    ++sorted;

    // Erase n from the graph
    for (auto& node : graph)
    {
      node.second.erase(n);
    }
    // After removing n, find any nodes that no longer
    // have any incoming edges.
    topsort_clean_graph(graph, nodes);
  }
  if (not graph.empty())
    throw std::runtime_error("Dependency graph contains cycles");
}

Listing 62-1.Topological Sort of a Directed Acyclic Graph

现在已经有了topological_sort函数,实现伪 make 程序来读取和解析输入,构建依赖图,调用topological_sort,并打印排序后的结果。保持简单,将工件(目标和依赖)视为字符串。因此,依赖图是一个以std::string为键类型、std::unordered_set<std::string>为值类型的映射。(映射和集合不需要按字母顺序排列,所以使用无序容器。)将您的解决方案与清单 62-2 进行比较。

#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;

import topsort;

using artifact = std::string; ///< A target, dependency, or both

class dependency_graph
{
public:
  using graph_type=std::unordered_map<artifact, std::unordered_set<artifact>>;

  void store_dependency(artifact const& target, artifact const& dependency)
  {
    graph_[dependency].insert(target);
    graph_[target]; // ensures that target is in the graph
  }

  graph_type const& graph() const { return graph_; }

  template<class OutIter>
  requires std::output_iterator<OutIter, artifact>
  void sort(OutIter sorted)
  const
  {
    topological_sort(graph_, sorted);
  }

private:
  graph_type graph_;
};

int main()
{

  dependency_graph graph{};

  std::string line{};
  while (std::getline(std::cin, line))
  {
    std::string target{}, dependency{};
    std::istringstream stream{line};
    if (stream >> target >> dependency)
      graph.store_dependency(target, dependency);
    else if (not target.empty())
      // Input line has a target with no dependency,
      // so report an error.
      std::cerr << "malformed input: target, " << target <<
                   ", must be followed by a dependency name\n";
    // else ignore blank lines
  }

  try {
    // Get the artifacts in dependency order.
    std::vector<artifact> sorted{};
    graph.sort(std::back_inserter(sorted));
    // Print in build order, which is reverse of dependency order.
    for (auto const& artifact: sorted | std::ranges::views::reverse)
      std::cout << artifact << '\n';
  } catch (std::runtime_error const& ex) {
    std::cerr << ex.what() << '\n';
    return EXIT_FAILURE;
  }
}

Listing 62-2.First Draft of the Pseudo-make Program

那么 Dag 和拓扑排序与这次探索的主题有什么关系呢?我以为你不会问。让我们通过使它更现实一点来构建一个稍微复杂一点的问题。

一个真正的make程序必须记录更多关于工件的信息,尤其是最后一次修改的时间。如果任何依赖项比目标更新,目标也有一个要执行的操作列表。因此,对于表示工件,类比字符串更有意义。您可以为您的make程序添加任何您需要的功能。

std::filesystem::last_write_time()查询文件的修改类型,在<filesystem>中声明。返回的时间类型为std::filesystem::file_time_type,可以用普通的比较运算符进行比较。忽略build()动作,您可以定义如清单 62-3 所示的artifact类型。

export module artifact;
import <filesystem>;
import <string>;
import <system_error>;

export class artifact
{
public:
  using file_time_type = std::filesystem::file_time_type;
  artifact() : name_{}, mod_time_{file_time_type::min()} {}
  artifact(std::string const& name)
  : name_{name}, mod_time_{get_mod_time()}
  {}

  std::string const& name() const { return name_; }
  file_time_type mod_time() const { return mod_time_; }

  /// Builds a target.
  /// After completing the actions (not yet implemented),
  /// update the modification time.
  void build();

  /// Looks up the modification time of the artifact.
  /// Returns file_time_type::min() if the artifact does not
  /// exist (and therefore must be built) or if the time cannot
  /// be obtained for any other reason.
  file_time_type get_mod_time()
  {
    std::error_code ec;
    auto time{ std::filesystem::last_write_time(name_, ec) };
    if (ec)
        return file_time_type::min();
    else
        return time;
  }
private:
  std::string name_;
  file_time_type mod_time_;
};

Listing 62-3.New Definition of an Artifact

现在我们遇到了一个问题。在这个程序的第一稿中,两个字符串引用同一个工件是因为这两个字符串有相同的内容。名为“program”的目标与名为“program”的依赖项是同一个工件,因为它们的拼写相同。既然一个工件不仅仅是一个字符串,那么这个方案就失败了。当您构建一个目标并更新其修改时间时,您希望该工件的所有使用都得到更新。不知何故,工件名称的每次使用都必须与该名称的单个工件对象相关联。

有什么想法吗?以你目前对 C++ 的理解是可以做到的,但是你可能要停下来想一想。

需要提示吗?将所有工件存储在一个大向量中如何?然后制作一个依赖图,包含向量的索引,而不是工件名称。试试看。重写清单 62-2 中的程序,使用清单 62-3 中的新artifact模块。当从输入文件中读取工件名称时,在所有工件的向量中查找该名称。如果神器是新的,就加到最后。在依赖图中存储向量索引。通过查找向量中的数字来打印最终列表。将您的解决方案与清单 62-4 进行比较。

Note

如果您担心线性查找的性能,那么恭喜您思维敏捷。不过,不要担心,因为在整个探索过程中,该程序将继续增长和发展,我们将在结束之前消除性能问题。

#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;

import artifact;
import topsort;

using artifact_index = std::size_t;

class dependency_graph
{
public:
  using graph_type = std::unordered_map<artifact_index,
      std::unordered_set<artifact_index>>;

  void store_dependency(artifact_index target, artifact_index dependency)
  {
    graph_[dependency].insert(target);
    graph_[target]; // ensures that target is in the graph
  }

  graph_type const& graph() const { return graph_; }

  template<class OutIter>
  requires std::output_iterator<OutIter, artifact_index>
  void sort(OutIter sorted)
  const
  {
    topological_sort(graph_, sorted);
  }

private:
  graph_type graph_;
};

std::vector<artifact> artifacts{};

artifact_index lookup_artifact(std::string const& name)
{
  auto iter{ std::find_if(artifacts.begin(), artifacts.end(),
    &name { return a.name() == name; })
  };
  if (iter != artifacts.end())
    return iter - artifacts.begin();
  // Artifact not found, so add it to the end.
  artifacts.emplace_back(name);
  return artifacts.size() - 1;
}

int main()
{
  dependency_graph graph{};

  std::string line{};
  while (std::getline(std::cin, line))
  {
    std::string target_name{}, dependency_name{};
    std::istringstream stream{line};
    if (stream >> target_name >> dependency_name)
    {
      artifact_index target{lookup_artifact(target_name)};
      artifact_index dependency{lookup_artifact(dependency_name)};
      graph.store_dependency(target, dependency);
    }
    else if (not target_name.empty())
      // Input line has a target with no dependency,
      // so report an error.
      std::cerr << "malformed input: target, " << target_name <<
                   ", must be followed by a dependency name\n";
    // else ignore blank lines
  }

  try {
    // Get the artifact indices in dependency order.
    std::vector<artifact_index> sorted{};
    graph.sort(std::back_inserter(sorted));
    // Print in build order, which is reverse of dependency order.
    for (auto index: sorted | std::ranges::views::reverse)
      std::cout << artifacts.at(index).name() << '\n';
  } catch (std::runtime_error const& ex) {
    std::cerr << ex.what() << '\n';
    return EXIT_FAILURE;
  }
}

Listing 62-4.Second Draft, After Adding Modification Times to Artifacts

嗯,这很有效,但是很难看。查找索引是草率的编程。更好的方法是直接在图中存储对artifact对象的引用。啊,问题就在这里。不能在标准容器中存储引用。容器是用来存储对象的——真实的对象。容器必须能够复制和分配容器中的元素,但它不能通过引用做到这一点。复制引用实际上是复制它所引用的对象。引用不是程序可以操纵的一级实体。

如果 C++ 有一个类似于引用的语言特性,但允许您复制和赋值引用本身(而不是被引用的对象),这不是很好吗?假设我们正在发明 C++ 语言,我们必须添加这种语言功能。

解决方案

让我们设计一种新的语言特性来解决这个编程问题。这个新特性类似于引用,但允许与标准容器一起使用。让我们称这个特性为 flex-ref ,是“灵活引用”的缩写。

如果ab都是引用类型int的可变引用,则语句

a = b;

意味着a的值改变,使得a现在引用 b 所引用的同一个int对象。将a作为参数传递给函数会传递a的值,所以如果函数给a分配一个新值,这个变化对函数来说是局部的(就像其他函数参数一样)。然而,使用合适的操作符,该函数可以获得a引用的int对象,并读取或修改该int

你需要一种方法来获得引用的值,所以我们必须发明一个新的操作符。看看迭代器:给定一个迭代器,一元运算符*返回迭代器引用的项。让我们对 flex-refs 使用相同的操作符。因此,下面打印出a引用的int值:

std::cout << *a;

按照*操作符的精神,使用*声明一个 flex-ref,就像使用&作为引用一样。

int *a, *b;

声明容器时使用相同的语法。例如,声明一个引用类型int的 flex-refs 向量。

std::vector<int*> vec;
vec.push_back(a);
b = vec.front();

剩下的工作就是提供一种方法让 flex-ref 引用一个对象。为此,让我们从普通参考文献中寻找灵感,并使用&操作符。假设cint类型,下面让a指代c:

a = &c;

正如您现在已经猜到的,flex-refs 是指针。变量ab被称为“指向int的指针”指针是一个真正的左值。它占用内存。存储在该存储器中的值是其他左值的地址。您可以自由地更改存储在该内存中的值,这样可以使指针指向不同的对象。

指针可以指向一个const对象,也可以是一个const指针,或者两者兼而有之。下面显示的是指向const int的指针:

int const* p;
p = &c;

定义一个const指针——也就是说,指针本身是const,因此不能作为赋值的目标,但是被解引用的对象可以是目标。

int * const q{&c};
*q = 42;

像任何const对象一样,你必须提供一个初始化器,并且你不能修改指针。但是,您可以修改指针指向的对象。

您可以定义对指针的引用,就像您可以定义对任何东西的引用一样(除了另一个引用)。

int const*&r{p};

像阅读任何其他声明一样阅读这个声明:首先找到声明符,r。然后从内向外阅读宣言。向左看&,告诉你r是参考。右边是初始化器,{p}r是对p的引用(r是对象p的别称)。继续向左,你会看到*,所以r是对指针的引用。然后你看到了const,所以你知道r是一个指向const的指针的引用。最后,int告诉你r是指向const int的指针的引用。因此,初始化器是有效的,因为它的类型是指向int的指针。

反过来呢?你能定义一个指向引用的指针吗?简单的回答就是不能。指向引用的指针和指向引用的引用一样没有意义。引用和指针必须引用或指向真实对象。当您在引用上使用&操作符时,您将获得被引用对象的地址。

你可以定义一个指向指针的指针,或者指向指针的指针指向指针。只要记录下你的指针的确切类型。编译器确保您只分配正确类型的表达式,如下所示:

int x;
int *y;
int **z;
y = &x;
z = &y;

试试 z = &x y = z 。会发生什么?


因为x有类型int&x有类型int*y也有类型int*,所以你可以将&x赋给y,但不能赋给z,后者有类型int**。类型必须匹配,所以也不能将z赋给y

我花了很长时间才说到重点,但是现在你可以看到指针是如何帮助解决写依赖图的问题的。然而,在深入研究代码之前,让我们花点时间来澄清一些术语。

地址与指针

程序员注重细节。我们每天使用的编译器和其他工具迫使我们这样做。所以让我们绝对清楚地址和指针。

一个地址是一个内存位置。用 C++ 的说法,它是一个右值,所以你不能修改或分配一个地址。当一个程序获取一个对象的地址时(用&操作符),结果在该对象的生命周期内是一个常量。像所有其他右值一样,C++ 中的地址也有类型,它必须是指针类型。

一个指针类型被更恰当地称为一个地址类型,因为该类型所代表的值的范围就是地址。尽管如此,术语指针类型更常见,因为指针对象有一个指针类型。

指针类型可以表示多级间接寻址—它可以表示指向指针的指针,或者指向指针的指针,等等。必须用星号声明每一级指针间接寻址。换句话说,int*是“指向int的指针”类型,int**是“指向int指针的指针”

指针是一个具有指针类型的左值。像任何对象一样,指针对象在内存中有一个位置,程序可以在其中存储一个值。该值的类型必须与指针的类型兼容;该值必须是正确类型的地址。

依赖图

现在让我们回到依赖图。图形可以存储指向工件的指针。每个外部文件对应程序中的一个artifact对象。该工件在图中可以有许多指向它的节点。如果您更新该工件,所有指向该工件的节点都会看到更新。因此,当构建规则更新工件时,文件修改时间可能会改变。图中该工件的所有节点都会立即看到新时间,因为它们都指向一个对象。

剩下要弄清楚的就是这些藏物在哪里。为了简单起见,我推荐一个map,以工件名称为关键字。映射的值是artifact对象(不是指针)。获取图中工件的地址,以获得存储在图中的指针。说吧;不要等我。使用topsortartifact模块,重写清单 62-4 来存储图中的artifact对象和图中的artifact指针。将您的解决方案与清单 62-5 进行比较。

#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <map>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;

import artifact;
import topsort;

class dependency_graph
{
public:
  using graph_type = std::unordered_map<artifact*,
                         std::unordered_set<artifact*>>;

  void store_dependency(artifact* target, artifact* dependency)
  {
    graph_[dependency].insert(target);
    graph_[target]; // ensures that target is in the graph
  }

  graph_type const& graph() const { return graph_; }

  template<class OutIter>
  requires std::output_iterator<OutIter, artifact*>
  void sort(OutIter sorted)
  const
  {
    topological_sort(graph_, sorted);
  }

private:
  graph_type graph_;
};

std::map<std::string, artifact> artifacts{};

artifact* lookup_artifact(std::string const& name)
{
  auto a( artifacts.find(name) );
  if (a != artifacts.end())
    return &a->second;
  else
  {
    auto [iterator, inserted]{ artifacts.emplace(name, name) };
    return &iterator->second;
  }
}

int main()
{
  dependency_graph graph{};

  std::string line{};
  while (std::getline(std::cin, line))
  {
    std::string target_name{}, dependency_name{};
    std::istringstream stream{line};
    if (stream >> target_name >> dependency_name)
    {
      artifact* target{lookup_artifact(target_name)};
      artifact* dependency{lookup_artifact(dependency_name)};
      graph.store_dependency(target, dependency);
    }
    else if (not target_name.empty())
      // Input line has a target with no dependency, so report an error.
      std::cerr << "malformed input: target, " << target_name <<
                   ", must be followed by a dependency name\n";
    // else ignore blank lines
  }

  try {
    // Get the sorted artifacts.
    std::vector<artifact*> sorted{};
    graph.sort(std::back_inserter(sorted));
    // Print in build order, which is reverse of dependency order.
    for (auto artifact : sorted | std::ranges::views::reverse)
    {
      std::cout << artifact->name() << '\n';
    }
  } catch (std::runtime_error const& ex) {
    std::cerr << ex.what() << '\n';
    return EXIT_FAILURE;
  }
}

Listing 62-5.Storing Pointers in the Dependency Graph

总的来说,程序需要最小的改变,而改变大部分是简化的。随着程序变得越来越复杂(实际程序不可避免地会这样),指针的简单和优雅变得越来越明显。

一个新的特点是lookup_artifact中一个奇怪的陈述:

auto [iterator, inserted]{ artifacts.emplace(name, name) };

这是一个定义了两个变量的声明,iteratorinserted。这些变量的初始值是从emplace()函数返回的值,该函数返回一个std::pair。编译器解包该对,用该对的成员first初始化iterator,用成员second初始化inserted。这种声明被称为结构化绑定

artifact对象存储在std::map而不是unordered_map中,因为当工件被添加到映射中时,指向工件的指针必须保持有效。在unordered_map的情况下,它可能需要增加它的哈希表的大小,这可能意味着在内存中移动所有的工件对象。这将使依赖图所包含的所有指针失效。因为std::map使用树结构,向树中添加节点不需要改变现有工件的存储位置。我们将重新考虑这个问题,但是首先让我们增强解析器。下一个探索着眼于如何使用正则表达式来解析 makefile。