四十七、范围、视图和适配器
在本书的前面,我已经谈到了“范围”,只是模糊地描述了一个范围到底是什么。一部分是因为范围是用 C++ 相当高级的特性定义的,我还没有介绍过,另一部分是因为 C++ 使用范围的方式,你不需要了解更多。这种探索开始揭开范围的神秘,并给你一些更有趣的冒险,包括一些非常强大的使用范围视图和范围适配器的编码技术。
范围
到目前为止,您已经遇到了两种类型的范围:向量和迭代器对。向量是一个范围,因为它存储了一系列值,而范围是访问这些值的一种方式。一对迭代器也可以通过解引用和递增起始迭代器直到它等于结束迭代器来表示一个范围。
更准确地说,范围由起始迭代器和结束标记来表征。一个 sentinel 可以是一个结束迭代器,但它不是必须的。sentinel 可能是代表范围终点的其他类型。链表的一个常见实现是使用一个 sentinel 节点来标记链表的末尾,因为这样比试图定义一个结束迭代器更容易编写代码。除非你想实现一个 range 类型,否则你不需要关心哨兵,除非你知道他们的存在。
所以一个范围有一个开始迭代器和结束标记。std::ranges::begin()
函数返回范围的开始,std::ranges::end()
返回标记。一些代码示例使用了data.begin()
和data.end()
,它们适用于向量,但不是每个范围。std::ranges
功能适用于所有范围类型。正如cbegin()
成员函数返回一个 const_iterator 一样,std::ranges::cbegin()
函数对任何范围都做同样的事情。
如果一个范围有一个已知的大小,比如一个vector
,std::ranges::size()
函数返回该大小。该函数仅针对可以快速返回其大小的范围类型定义。其他的,比如istream_view
,根本就没有对应的std::ranges::size()
功能。类似地,如果范围为空,std::ranges::empty()
返回 true,但只有在不修改范围的情况下可以确定范围时才被定义。所以不能用它来测试一个std::ranges::istream_view
的实例是否为空,但是可以用它来测试一个vector
。
清单 47-1 展示了其中一些范围函数。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
int main()
{
std::vector<int> data;
std::cout << "Enter some numbers:\n";
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(data));
std::cout << "You entered " << std::ranges::size(data) << " values\n";
if (not std::ranges::empty(data))
{
std::ranges::sort(data);
auto start{ std::ranges::cbegin(data) };
auto middle{ start + std::ranges::size(data) / 2 };
std::cout << "The median value is " << *middle << '\n';
}
}
Listing 47-1.Demonstrating Range Functions
给定一个迭代器和一个兼容的标记,您可以通过将迭代器和标记传递给构造器来构造一个std::ranges::subrange
对象,从而定义一个范围。子区域不从源区域复制任何元素。它只是抓住迭代器和哨兵。正如std::advance
推进一个迭代器,std::next()
返回一个新迭代器一样,subrange 类实现了advance()
成员函数来推进起始迭代器。成员函数next()
返回一个新的子范围,其起始位置前进了一位。这两个函数都需要一个参数来提升多个位置。例如,下面将数字 3、4 和 5 打印到标准输出中:
std::vector<int> data{ 1, 2, 3, 4, 5 };
std::ranges::subrange sub{ std::ranges::begin(data), std::ranges::end(data) };
std::ranges::copy(sub.next(2), std::ostream_iterator<int>(std::cout, "\n"));
正如迭代器有不同的风格一样,范围也是如此。输入范围是以输入迭代器作为起始迭代器的范围。输出范围有一个起始输出迭代器。其他迭代器类型也有相应的范围类型:forward_range、bidirectional_range、random_access_range 和 contiguous_range。范围的行为和限制与它们各自的迭代器相同。
范围有额外的特征,比如common_range
,这是 sentinel 类型与起始迭代器类型相同的时候。标准库中的所有容器类型(如vector
)都是公共范围。一个viewable_range
是一个可以用作视图的范围,这是下一节的主题。
范围视图
视图是一种特殊的范围,其特点是轻量级复制。复制一个视图只是使另一个视图看起来和原始视图一样。销毁视图(例如当视图超出范围时)是即时的,因为查看范围内没有元素被销毁。子范围是一种视图,其他类型的视图包括single_view
,它获取单个对象并使其看起来像一个大小为 1 的范围。
std::ranges::iota_view
型类似于清单 44-5 的sequence
级。它接受一个或两个参数,并生成一个整数范围,带有一个起始值和一个可选的标记值。如果没有 sentinel 值,该范围将永远持续下去,直到整数值绕回并重复。
视图类型的一个有趣的方面是,您可以用两种不同的方式来构造一个视图。调用std::ranges::view::iota(start)
函数相当于构造std::ranges::iota_view{start}
。传递两个参数也是如此。
用std::ranges::single_view
做实验。**写一个程序,从用户那里读取单个整数,构造一个 single_view,使用 ranged for 循环打印视图的每个元素。**在清单 47-2 中将你的程序与我的程序进行比较。
import <iostream>;
import <ranges>;
int main()
{
std::cout << "Enter an integer: ";
int input{};
if (std::cin >> input)
{
for (auto x : std::ranges::single_view{input}) {
std::cout << x << '\n';
}
}
}
Listing 47-2.Demonstrating Range Functions
范围管道
视野很可爱,但是有什么用呢?标准库将竖线操作符(|
)定义为管道操作符,能够将数据从一个范围输送到视图管道中。例如,假设您正在为一个裁判事件编写评分软件。评分规则是舍弃高低分,计算剩余分数的平均值。你可以用迭代器做到这一点,但是调整起始迭代器和结束迭代器会很笨拙。或者你可以修改存储分数的向量,去掉第一个和最后一个。或者您可以使用视图,如清单 47-3 所示。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
int main()
{
std::cout << "Enter the scores: ";
std::vector<int> scores{};
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(scores));
std::ranges::sort(scores);
auto drop_high{ scores | std::ranges::views::take(scores.size() - 1) };
auto remaining_scores{ drop_high | std::ranges::views::drop(1) };
int sum{0};
int count{0};
for (int score : remaining_scores)
{
++count;
sum += score;
}
std::cout << "mean score is " << sum / count << '\n';
}
Listing 47-3.Computing Scores by Using Views
与其他视图一样,take
和drop
视图值得注意的是,分数向量永远不会被复制。取而代之的是,遍历一次向量,以很小的开销记录相关的分数。使这些管道如此有效的是对其数据执行处理的特殊视图。这些特殊视图被称为范围适配器,这是下一节的主题。
范围适配器
范围适配器是给定范围时创建视图的另一种方式。标准库包括几个有用的范围适配器,第三方可能会添加更多。
drop
视图
std::ranges::view::drop
适配器接受一个整数参数,跳过输入范围中的许多元素,传递所有后续元素,例如,跳过一个字符串的前两个字符(毕竟这是一个字符范围):
std::string string{"string"};
auto ring = string | std::ranges::views::drop(2);
drop_while
适配器与此类似,但是它使用谓词而不是计数。它跳过元素,直到谓词返回 true,并迭代该元素,然后是其后的所有剩余元素。
filter
视图
使用std::ranges::view::filter(predicate)
仅选择输入范围中通过谓词的元素。通常,这可以是一个函数、仿函数或 lambda,例如,只选择大于零的值:
auto positives = data
| std::ranges::views::filter([](auto value) { return value > 0; });
join
视图
用std::ranges::view::join
将一系列范围展平为单个范围。join 的常见用法是将一系列字符串连接成一系列字符,例如:
std::vector<std::string> words{ "this", " ", "is an", " ", "example" };
auto sentence = words | std::ranges::views::join;
keys
视图
对 map 进行迭代会为每个元素生成一对键和值。通常,您只需要迭代键,这可以用std::ranges::view::keys
来完成,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
for (auto const& animal : barn | std::ranges::views::keys)
std::cout << animal << '\n';
reverse
视图
如果范围是双向的,std::ranges::views::reverse
适配器以相反的顺序迭代它,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
auto animals{ barn | std::ranges::views::reverse | std::ranges::views::keys };
for (auto const& animal : animals)
std::cout << animal << '\n';
transform
视图
std::ranges::view::transform
适配器接受一个函数或 lambda 参数,并将范围内的每个元素传递给该函数,用该函数返回的值替换元素,例如,将字符串范围更改为整数字符串长度范围,然后只保留大于三的长度:
std::vector<std::string> strings{"string", "one", "two", "testing" };
auto sizes = strings
| std::ranges::views::transform([](auto&& str) { return str.size(); })
| std::ranges::views::filter([](auto size) { return size > 3; });
take
视图
std::ranges::view::take
适配器接受一个整数参数,并产生输入范围的多个元素,例如,从第一个元素开始,只保留字符串的前三个字符:
std::string string{"string"};
std::ranges::copy(string | std::ranges::views::take(3),
std::ostreambuf_iterator(std::cout));
take_while
适配器与此类似,但是它使用谓词而不是计数。take_while
适配器迭代元素,直到谓词返回 false,之后它停止迭代输入范围的剩余部分。
values
视图
对 map 进行迭代会为每个元素生成一对键和值。通常,您只需要迭代值,这可以用std::ranges::view::values
来完成,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
int total{0};
for (auto count : barn | std::ranges::views::values)
total += count;
这不是一个详尽的列表,尽管它涵盖了大多数视图适配器。这些示例展示了使用管道组装视图适配器的几种方式。如果你喜欢函数调用语法,C++ 提供了一点灵活性。以下管道都做同样的事情,创建一个[2, 7)
的视图:
auto data{ std::ranges::views::iota(0, 10) }; // [0, 10)
auto demo1 = data | std::ranges::views::drop(2) | std::ranges::views::take(5);
auto demo2 = std::ranges::views::drop(data, 2) | std::ranges::views::take(5);
auto demo3 = std::ranges::views::take(std::ranges::views::drop(data, 2), 5);
auto demo4 = std::ranges::views::take(5)(std::ranges::views::drop(2)(data));
许多程序都涉及到在一个范围内迭代。这可能是编程最基本的方面。语言提供了各种各样的结构来遍历一个范围,C++ 有三种不同的循环。最后,您可以使用 ranged for 循环、范围、视图和适配器做任何事情。
现在是时候学习一些更重要的 C++ 编程技术了。下一篇文章将介绍异常和异常处理,这是正确处理程序员和用户错误的必要主题。
四十八、异常
到目前为止,您可能已经对探索中缺乏错误检查和错误处理感到沮丧。这种情况即将改变。像大多数现代编程语言一样,C++ 支持异常作为跳出正常控制流程的一种方式,以响应错误或其他异常情况。这种探索引入了异常:如何抛出它们,如何捕捉它们,语言和库何时使用它们,以及您应该何时以及如何使用它们。
引入异常
Exploration 9 引入了vector
的at
成员函数,该函数检索特定索引处的向量元素。当时我写道,你所阅读的大多数程序都会用方括号来代替。现在是检查方括号和at
函数之间的区别的好时机。首先,看两个程序。清单 48-1 显示了一个使用向量的简单程序。
import <iostream>;
import <vector>;
int main()
{
std::vector<int> data{ 10, 20 };
data.at(5) = 0;
std::cout << data.at(5) << '\n';
}
Listing 48-1.Accessing an Element of a Vector
运行该程序时,您预计会发生什么?
试试看。实际上会发生什么?
向量索引 5 超出界限。data
的唯一有效索引是 0 和 1,所以程序以 nastygram 结束也就不足为奇了。现在考虑清单 48-2 中的程序。
import <iostream>;
import <vector>;
int main()
{
std::vector<int> data{ 10, 20 };
data[5] = 0;
std::cout << data[5] << '\n';
}
Listing 48-2.A Bad Way to Access an Element of a Vector
运行该程序时,您预计会发生什么?
试试看。实际上会发生什么?
向量索引 5 仍然超出界限。如果你仍然收到一个讨厌的程序,你会得到一个不同于以前的程序。另一方面,程序可能运行到完成,而没有指示任何错误。您可能会觉得这令人不安,但这就是未定义行为的情况。任何事情都有可能发生。
简而言之,这就是使用下标([]
)和at
成员函数的区别。如果索引无效,at
成员函数会导致程序以一种可预测、可控的方式终止。您可以编写额外的代码并避免终止,采取适当的措施在终止前进行清理,或者让程序结束。
另一方面,如果索引无效,下标操作符会导致未定义的行为。任何事情都可能发生,所以你无法控制——一点也不能。如果软件正在控制,比如说,一架飞机,那么“任何事情”都包含许多令人难以想象的选项。在一个典型的桌面工作站上,更可能的情况是程序崩溃,这是一件好事,因为它告诉你有什么地方出错了。最糟糕的可能后果是,没有明显的事情发生,程序默默地使用一个垃圾值并继续运行。
成员函数at
和许多其他函数可以抛出异常来提示错误。当一个程序抛出一个异常时,正常的、一条条语句的程序进程被中断。相反,一个特殊的异常处理系统控制程序。该标准为这个系统的实际工作方式提供了一些余地,但是您可以想象它会强制函数结束并破坏本地对象和参数,尽管这些函数不会向调用者返回值。相反,函数被强制结束,一次一个,一个特殊的代码块捕获异常。使用try
- catch
语句在程序中设置这些特殊代码块。catch
模块也被称为异常处理器。处理程序完成工作后,正常的代码执行会继续:
try {
throw std::runtime_error("oops");
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
}
当程序抛出一个异常时(用throw
关键字),它抛出一个值,称为异常对象,它可以是几乎任何类型的对象。按照惯例,异常类型,比如std::runtime_error
,继承自std::exception
类或者标准库提供的几个子类之一。第三方类库经常引入自己的异常基类。
异常处理程序还有一个对象声明,它有一个类型,处理程序只接受匹配类型的异常对象。如果没有一个异常处理程序有匹配的类型,或者如果你根本没有写任何处理程序,程序就会终止,就像清单 48-1 中发生的那样。本文的其余部分将详细研究异常处理的各个方面。
捕捉异常
一个异常处理器被称为捕捉一个异常。在一个try
的末尾写一个异常处理程序:try
关键字后面是一个复合语句(必须是复合的),后面是一系列处理程序。每个处理程序都以一个catch
关键字开始,后面是圆括号,括号中包含了异常处理程序对象的声明。括号后面是一个复合语句,它是异常处理程序的主体。
当异常对象的类型与异常处理程序对象的类型匹配时,处理程序被认为是匹配的,并且处理程序对象用异常对象初始化。处理程序声明通常是一个引用,这样可以避免不必要地复制异常对象。大多数处理程序不需要修改异常对象,所以处理程序声明通常是对const
的引用。“匹配”是当异常对象的类型与处理程序声明的类型或从处理程序声明的类型派生的类相同时,忽略处理程序是const
还是引用。
异常处理系统在抛出异常之前销毁它在语句的try
部分构造的所有对象,然后它将控制转移到处理程序,因此处理程序的主体正常运行,并且在整个try
- catch
语句结束后,也就是在语句的最后一个catch
处理程序结束后,控制随着语句恢复。按顺序尝试处理程序类型,第一个匹配者获胜。因此,您应该总是首先列出最具体的类型,然后列出基类类型。
基类异常处理程序类型匹配任何派生类型的异常对象。为了处理标准库可能抛出的所有异常,编写处理程序来捕捉std::exception
(在<exception>
中声明),这是所有标准异常的基类。清单 48-3 展示了std::string
类可以抛出的一些异常。通过键入不同长度的字符串来试用该程序。
import <cstdlib>;
import <exception>;
import <iostream>;
import <stdexcept>;
import <string>;
int main()
{
std::string line{};
while (std::getline(std::cin, line))
{
try
{
line.at(10) = ' '; // can throw out_of_range
if (line.size() < 20)
line.append(line.max_size(), '*'); // can throw length_error
for (std::string::size_type size(line.size());
size < line.max_size();
size = size * 2)
{
line.resize(size); // can throw bad_alloc
}
line.resize(line.max_size()); // can throw bad_alloc
std::cout << "okay\n";
}
catch (std::out_of_range const& ex)
{
std::cout << ex.what() << '\n';
std::cout << "string index (10) out of range.\n";
}
catch (std::length_error const& ex)
{
std::cout << ex.what() << '\n';
std::cout << "maximum string length (" << line.max_size() << ") exceeded.\n";
}
catch (std::exception const& ex)
{
std::cout << "other exception: " << ex.what() << '\n';
}
catch (...)
{
std::cout << "Unknown exception type. Program terminating.\n";
std::abort();
}
}
}
Listing 48-3.Forcing a string to Throw Exceptions
如果您键入包含 10 个或更少字符的行,line.at(10)
表达式将抛出std::out_of_range
异常。如果字符串多于 10 个字符,但少于 20 个字符,程序会尝试附加一个星号('*'
)的最大字符串长度重复,结果是std::length_error
。如果初始字符串超过 20 个字符,程序会尝试使用不断增长的大小来增加字符串的大小。最有可能的是,大小最终会超过可用内存,在这种情况下,resize()
函数将抛出std::bad_alloc
。如果你有很多很多的内存,下一个错误情况会迫使字符串大小达到string
支持的限制,然后尝试向字符串中添加另一个字符,这会导致push_back
函数抛出std::length_error
。(max_size
成员函数返回一个容器(比如std::string
)可以包含的最大元素数量。)
基类处理程序捕捉前两个处理程序错过的任何异常;特别是它抓住了std::bad_alloc
。what()
成员函数返回一个描述异常的字符串。字符串的确切内容因实现而异。任何重要的应用程序都应该定义自己的异常类,并对用户隐藏标准库异常。特别是,从what()
返回的字符串是实现定义的,不一定有用。捕捉bad_alloc
尤其棘手,因为如果系统内存不足,应用程序可能没有足够的内存在关闭前保存数据。你应该总是显式地处理bad_alloc
,但是我想演示一个基类的处理程序。
最后一个catch
处理程序使用省略号(...
)代替声明。这是一个匹配任何异常的无所不包的处理程序。如果使用它,它必须是 last,因为它匹配任何类型的每个异常对象。因为处理程序不知道异常的类型,所以它没有办法访问异常对象。这个包罗万象的处理程序打印一条消息,然后调用std::abort()
(在<cstdlib>
中声明),这将立即结束程序。因为std::exception
处理程序捕获所有标准库异常,所以并不真正需要最终的全部捕获处理程序,但是我想向您展示它是如何工作的。
抛出异常
一个抛出表达式抛出异常。表达式由关键字throw
后跟一个表达式组成,即异常对象。标准异常都接受一个string
参数,该参数成为从what()
成员函数返回的值。
throw std::out_of_range("index out of range");
标准库为自己的异常使用的消息是实现定义的,因此您不能依赖它们来提供任何有用的信息。
你可以在任何可以使用表达式的地方抛出异常。throw 表达式的类型是void
,这意味着它没有类型。类型void
不允许作为任何算术、比较或其他运算符的操作数。因此,实际上,throw
表达式通常单独用在表达式语句中。
您可以在 catch 处理程序中抛出异常,低级代码和库经常这样做。不使用throw
关键字,而是调用std::throw_with_nested()
,传递新的异常对象作为参数。throw_with_nested()
函数将您的异常对象与当前抛出的异常对象结合起来,并冒泡到下一个异常处理程序。正常情况下捕捉一个嵌套异常,但是如果你发现异常是嵌套的,处理程序必须一次剥离一个,如清单 48-4 所示。
import <exception>;
import <fstream>;
import <iomanip>;
import <iostream>;
import <stdexcept>;
void print_exception(const std::exception& e, int level = 0)
{
std::cerr << std::setw(level) << ' ' << "exception: " << e.what() << '\n';
try {
std::rethrow_if_nested(e);
} catch(const std::exception& e) {
// caught a nested exception
print_exception(e, level+1);
} catch(...) {}
}
int main()
{
std::string const filename{ "nonexistent file" };
std::ifstream file;
file.exceptions(std::ios_base::failbit);
try
{
file.open(filename);
}
catch (std::ios_base::failure const&)
{
std::throw_with_nested(std::runtime_error{"Cannot open: " + filename});
}
catch (...)
{
file.close();
throw;
}
}
Listing 48-4.Nested Exceptions
打开文件流将在后面介绍。这里只是一个例子,说明当处理程序可以向异常添加一些有用的信息时,抛出 I/O 异常的常见方式,特别是打开的文件的名称。嵌套异常的工作方式是每一层嵌套嵌入一个异常对象,而rethrow_if_nested()
实际上将该对象作为一个新的异常抛出。因此,处理程序递归地一次一层地取消对异常洋葱的感知。
如果您只想执行一些清理并重新抛出相同的异常,请使用不带任何表达式的throw
关键字。再次引发异常的常见情况是在一个无所不包的处理程序中。catch-all 处理程序执行一些重要的清理工作,然后传播异常,以便程序可以处理它。
程序栈
为了理解当一个程序抛出异常时会发生什么,你必须首先理解程序栈的性质,有时被称为执行栈。过程语言和类似的语言在运行时使用栈来跟踪函数调用、函数参数和局部变量。C++ 栈还有助于跟踪异常处理程序。
当程序调用一个函数时,程序将一个帧推到栈上。该帧包含指令指针和其他寄存器、函数的参数等信息,还可能包含一些函数返回值的内存。当一个函数启动时,它可能会在栈上为局部变量留出一些内存。每个局部作用域将一个新帧推送到栈上。(编译器可能能够为某些局部范围甚至整个函数优化掉一个物理框架。然而,从概念上讲,以下情况适用。)
当函数执行时,它通常会构造各种对象:函数参数、局部变量、临时对象等等。编译器跟踪函数必须创建的所有对象,这样当函数返回时,它可以正确地销毁它们。本地对象的销毁顺序与它们的创建顺序相反。
框架是动态的,也就是说,它们表示程序中的函数调用和控制流,而不是源代码的静态表示。因此,函数可以调用自己,每次调用都会在栈上产生一个新的框架,每个框架都有自己的所有函数参数和局部变量的副本。
当程序抛出异常时,正常的控制流停止,C++ 异常处理机制接管。异常对象被复制到一个安全的地方,离开执行栈。异常处理代码在栈中寻找一个try
语句。当它找到一个try
语句时,它依次检查每个处理程序的类型,寻找匹配。如果没有找到匹配,它将在栈中更靠后的位置寻找下一个try
语句。它会一直寻找,直到找到匹配的处理程序,或者搜索完所有帧。
当找到匹配时,它从执行栈中弹出帧,在每个弹出的帧中调用所有本地对象的析构函数,并继续弹出帧,直到到达处理程序。从栈中弹出帧也被称为展开栈。
展开栈后,异常对象初始化处理程序的异常对象,然后执行catch
体。在catch
体正常退出后,异常对象被释放,执行继续执行最后一个兄弟catch
块末尾后面的语句。
如果处理程序抛出异常,那么重新开始搜索匹配的处理程序。一个处理程序不能处理它抛出的异常,在同一个try
语句中它的兄弟处理程序也不能。
如果没有处理程序匹配异常对象的类型,就调用std::terminate
函数,中止程序。有些实现会在调用terminate
之前弹出栈并释放本地对象,但有些不会。
清单 48-5 可以帮助你想象当一个程序抛出和捕获一个异常时,程序内部发生了什么。
1 import <exception>;
2 import <iostream>;
3 import <string>;
4
5 /// Make visual the construction and destruction of objects.
6 class visual
7 {
8 public:
9 visual(std::string const& what)
10 : id_{serial_}, what_{what}
11 {
12 ++serial_;
13 print("");
14 }
15 visual(visual const& ex)
16 : id_{ex.id_}, what_{ex.what_}
17 {
18 print("copy ");
19 }
20 ~visual()
21 {
22 print("~");
23 }
24 void print(std::string const& label)
25 const
26 {
27 std::cout << label << "visual(" << what_ << ": " << id_ << ")\n";
28 }
29 private:
30 static int serial_;
31 int const id_;
32 std::string const what_;
33 };
34
35 int visual::serial_{0};
36
37 void count_down(int n)
38 {
39 std::cout << "start count_down(" << n << ")\n";
40 visual v{"count_down local"};
41 try
42 {
43 if (n == 3)
44 throw visual("exception");
45 else if (n > 0)
46 count_down(n - 1);
47 }
48 catch (visual ex)
49 {
50 ex.print("catch on line 50 ");
51 throw;
52 }
53 std::cout << "end count_down(" << n << ")\n";
54 }
55
56 int main()
57 {
58 try
59 {
60 count_down(2);
61 std::cout << "--------------------\n";
62 count_down(4);
63 }
64 catch (visual const ex)
65 {
66 ex.print("catch on line 66 ");
67 }
68 std::cout << "All done!\n";
69 }
Listing 48-5.Visualizing an Exception
类有助于显示对象何时以及如何被构造、复制和销毁。count_down
函数在其参数等于 3 时抛出异常,当其参数为正时调用自身。对于非正参数,递归停止。为了帮助您查看函数调用,它会在进入和退出函数时打印参数。
对count_down
的第一次调用不会触发异常,所以您应该看到本地visual
对象的正常创建和销毁。确切地写出程序应该打印的结果,如第 60 行( count_down(2)
) )。
从main
到count_down
的下一个调用(第 62 行)允许count_down
在抛出异常之前递归一次。所以count_down(4)
叫count_down(3)
。本地对象v
被构造在count_down(4)
的框架内,而v
的新实例被构造在count_down(3)
的框架内。然后创建并抛出异常对象。(参见图 48-1 。)
图 48-1。
引发异常时的程序栈
异常在count_down
内部被捕获,所以它的帧没有被弹出。然后异常对象被复制到ex
(第 48 行),异常处理程序开始。它打印一条消息,然后重新抛出原来的异常对象(第 51 行)。异常处理机制对待这个异常的方式与对待任何其他异常一样:弹出try
语句的框架,然后弹出count_down
函数的框架。本地物体被破坏(包括ex
和v
)。count_down
中的最后一条语句不执行。
栈被展开,调用count_down(4)
中的try
语句被找到,异常对象再次被复制到ex
的一个新实例中。(参见图 48-2 。)异常处理程序打印一条消息并重新引发原始异常。弹出count_down(4)
帧,将控制返回到main
中的try
语句。同样,count_down
中的最后一条语句不执行。
图 48-2。
再次引发异常后的程序栈
main
中的异常处理程序轮到它了,这个处理程序最后一次打印异常对象(第 66 行)。在处理程序打印一条消息,并且catch
主体到达它的结尾之后,本地异常对象和原始异常对象被销毁。然后在第 68 行继续正常执行。最终输出是
start count_down(2)
visual(count_down local: 0)
start count_down(1)
visual(count_down local: 1)
start count_down(0)
visual(count_down local: 2)
end count_down(0)
~visual(count_down local: 2)
end count_down(1)
~visual(count_down local: 1)
end count_down(2)
~visual(count_down local: 0)
--------------------
start count_down(4)
visual(count_down local: 3)
start count_down(3)
visual(count_down local: 4)
visual(exception: 5)
copy visual(exception: 5)
catch on line 50 visual(exception: 5)
~visual(exception: 5)
~visual(count_down local: 4)
copy visual(exception: 5)
catch on line 50 visual(exception: 5)
~visual(exception: 5)
~visual(count_down local: 3)
copy visual(exception: 5)
catch on line 66 visual(exception: 5)
~visual(exception: 5)
~visual(exception: 5)
All done!
标准异常
标准库定义了几种标准的异常类型。基类exception
在<exception>
头中声明。大多数其他异常类都在<stdexcept>
头中定义。如果您想定义自己的异常类,我建议从<stdexcept>
中的一个标准异常中派生出来。
标准异常分为两类(两个基类直接从exception
派生而来):
-
运行时错误(
std::runtime_error
)是您不能仅仅通过检查源代码来检测或防止的异常。它们产生于你可以预见,但无法预防的情况。 -
逻辑错误(
std::logic_error
)是程序员错误的结果。它们表示违反了前提条件、无效的函数参数以及程序员应该在代码中防止的其他错误。
<stdexcept>
中的其他标准异常类都源自这两个。大多数标准库异常都是逻辑错误。例如,out_of_range
继承自logic_error
。当索引超出范围时,at
成员函数和其他函数抛出out_of_range
。毕竟,您应该检查索引和大小,以确保向量和字符串的使用是正确的,并且不依赖于异常。当你犯了一个错误(我们都犯了错误)时,异常是为了让你的程序干净有序地关闭。
你的库引用告诉你哪些函数抛出哪些异常,比如at
可以抛出out_of_range
。许多函数也可能抛出其他未记录的异常,这取决于库和编译器的实现。然而,一般来说,标准库很少使用异常。相反,当您提供错误的输入时,大多数库会产生未定义的行为。I/O 流通常不抛出任何异常,但是您可以安排它们在发生严重错误时抛出异常,这将在下一节中解释。
I/O 异常
您在 Exploration 32 中学习了 I/O 流状态位。状态位很重要,但是反复检查它们很麻烦。特别是,许多程序无法检查输出流的状态位,尤其是在写入标准输出时。那只是普通的、老式的懒惰。幸运的是,C++ 为程序员提供了一条无需太多额外工作就能获得 I/O 安全性的途径:当 I/O 失败时,流可以抛出异常。
除了状态位,每个流还有一个异常掩码。异常掩码告诉流在相应的状态位改变值时抛出异常。例如,您可以在异常掩码中设置badbit
,并且永远不要为这种不太可能发生的情况编写显式检查。如果发生严重的 I/O 错误,导致badbit
被置位,那么流将抛出一个异常。你可以编写一个高级处理程序来捕捉异常并干净地终止程序,如清单 48-6 所示。
import <iostream>;
int main()
{
std::cin.exceptions(std::ios_base::badbit);
std::cout.exceptions(std::ios_base::badbit);
int x{};
try
{
while (std::cin >> x)
std::cout << x << '\n';
if (not std::cin.eof()) // failure without eof means invalid input
std::cerr << "Invalid integer input. Program terminated.\n";
}
catch(std::ios_base::failure const& ex)
{
std::cerr << "Major I/O failure! Program terminated.\n" <<
ex.what() << '\n';
std::terminate();
}
}
Listing 48-6.Using an I/O Stream Exception Mask
如您所见,异常类被命名为std::ios_base::failure
。还要注意一个新的输出流:std::cerr
。<iostream>
头实际上声明了几个标准的 I/O 流。到目前为止,我只用过cin
和cout
,因为这是我们所需要的。cerr
流是专用于错误输出的输出流。在这种情况下,将正常输出(到cout
)与错误输出(到cerr
)分开是很重要的,因为cout
可能会出现致命错误(比如磁盘已满),所以任何向cout
写入错误消息的尝试都是徒劳的。相反,程序将消息写入cerr
。不能保证写cerr
会成功,但至少有机会;例如,用户可能将标准输出重定向到一个文件(因此有遇到磁盘满错误的风险),同时允许错误输出出现在控制台上。
回想一下,当输入流到达输入的末尾时,它会在其状态掩码中设置eofbit
。虽然您也可以在异常掩码中设置这个位,但是我看不出您有什么理由要这样做。如果一个输入操作没有从流中读取任何有用的东西,流就会设置failbit
。流可能不读取任何内容的最常见原因是文件尾(eofbit
被设置)或输入格式错误(例如,当程序试图读取一个数字时,输入流中的文本)。同样,可以在异常掩码中设置failbit
,但是大多数程序依赖普通的程序逻辑来测试输入流的状态。异常是针对异常情况的,当从流中读取时,文件结束是正常现象。
当failbit
被设置时,循环结束,但是您必须进一步测试以发现failbit
是否被设置,这是因为正常的文件结束条件还是因为格式错误的输入。如果eofbit
也被设置,你就知道这个流已经结束了。否则,failbit
一定是由于输入格式错误。
如您所见,异常并不能解决所有的错误情况。因此,badbit
是异常掩码中唯一对大多数程序有意义的位,尤其是对输入流。如果输出流无法将整个值写入流中,它将设置failbit
。通常,这种故障是因为设置了badbit
的 I/O 错误而发生的,但是至少理论上有可能输出故障设置了failbit
而没有设置badbit
。在大多数情况下,任何输出失败都是警报的原因,所以您可能希望对输出流的failbit
和输入流的badbit
抛出一个异常。
std::cin.exceptions(std::ios_base::badbit);
std::cout.exceptions(std::ios_base::failbit);
自定义异常
异常通过从主控制流中移除异常条件来简化编码。对于许多错误情况,您可以也应该使用异常。例如,rational
级(最近出现在探索 41 中)到目前为止完全避免了被零除的问题。比调用未定义的行为(被零除时会发生这种情况)更好的解决方案是在分母为零时抛出异常。通过从一个标准异常基类派生来定义自己的异常类,如清单 48-7 所示。通过定义自己的异常类,rational
的任何用户都可以很容易地将其异常与其他异常区分开来。
export module rational;
import <stdexcept>;
import <string>;
export class rational
{
public:
class zero_denominator : public std::logic_error
{
public:
using std::logic_error::logic_error;
};
rational() : rational{0} {}
rational(int num) : numerator_{num}, denominator_{1} {}
rational(int num, int den) : numerator_{num}, denominator_{den}
{
if (denominator_ == 0)
throw zero_denominator{"zero denominator"};
reduce();
}
... omitted for brevity ...
};
Listing 48-7.Throwing an Exception for a Zero Denominator
注意zero_denominator
类是如何嵌套在rational
类中的。嵌套类是一个非常普通的类。除了名称之外,它与外部类没有任何关系(与 Java 内部类一样)。嵌套类不能对外部类中的私有成员进行特殊访问,外部类也不能对嵌套类名进行特殊访问。访问级别的常规规则决定了嵌套类的可访问性。一些嵌套类是私有帮助类,所以你可以在外部类定义的私有部分声明它们。在这种情况下,zero_denominator
必须是公共的,这样调用者就可以在异常处理程序中使用这个类。
要在外部类之外使用嵌套类名,必须使用外部类和嵌套类名,用范围运算符(::
)分隔。嵌套类名在外部类的范围之外没有意义。因此,嵌套类有助于避免名称冲突。它们还为在异常处理程序中看到该类型的读者提供了清晰的文档:
catch (rational::zero_denominator const& ex) {
std::cerr << "zero denominator in rational number\n";
}
找到 rational 类中所有其他需要检查零分母的地方,并添加适当的错误检查代码来抛出零分母。
所有的路都通向reduce()
,所以一种方法是检查一个零分母,并在那里抛出异常。你不必修改任何其他函数,甚至在构造器中的额外检查(如清单 48-6 所示)也是不必要的。清单 48-8 显示了reduce()
的最新实现。
void rational::reduce()
{
if (denominator_ == 0)
throw zero_denominator{"denominator is zero"};
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
Listing 48-8.Checking for a Zero Denominator in reduce()
当函数不抛出异常时
某些函数不应该抛出异常。例如,numerator()
和denominator()
函数只是返回一个整数。他们不可能抛出异常。如果编译器知道函数从不抛出异常,它可以生成更有效的目标代码。有了这些特定的函数,编译器可能会内联扩展这些函数来直接访问数据成员,所以理论上,这并不重要。但是也许你决定不内联这些函数(出于探索 31 中列出的任何原因)。您仍然希望能够告诉编译器,函数不能抛出任何异常。进入noexcept
资格赛。
为了告诉编译器一个函数不抛出异常,在函数参数之后添加noexcept
限定符(在const
之后,override
之前)。
int numerator() const noexcept;
如果你中断联系会怎么样?**试试吧。**写一个程序,调用一个被限定为 noexcept 的普通函数,但是抛出一个异常。尝试捕获main()
中的异常。会发生什么?
如果你的程序看起来像我清单 48-9 中的程序,那么catch
应该会捕捉到异常,但是它没有。编译器信任noexcept
,没有生成正常的异常处理代码。因此,当function()
抛出异常时,程序唯一能做的就是立即终止。
import <iostream>;
import <exception>;
void function() noexcept
{
throw std::exception{};
}
int main()
{
try {
function();
} catch (std::exception const& ex) {
std::cout << "Gotcha!\n";
}
}
Listing 48-9.Throwing an Exception from a noexcept Function
明智地使用noexcept
。如果函数a()
只调用被标记为noexcept
的函数,那么a()
的作者可能也会决定使用a() noexcept
。但是如果其中一个函数,比如说b()
,改变了,不再是noexcept
,那么a()
就有麻烦了。如果b()
抛出一个异常,程序会毫不客气地终止。所以只有在你能保证函数现在不会抛出异常,将来也永远不会改变来抛出异常的情况下,才使用noexcept
。所以numerator()
和denominator()
在rational
类中是noexcept
大概是安全的,默认和单参数构造器也是,但是我想不出还有什么成员函数可以是noexcept
。
系统错误
探索 14 引入了<system_error>
头来显示程序无法打开文件时的错误信息。<system_error>
的目的是提供一种可移植的方法来管理错误代码、条件和消息。它很好地支持 POSIX 标准错误代码,但是将实现留给了其他操作系统。因此,请阅读您的文档以了解您的操作系统的支持。
一个std::error_category
定义了你的系统支持的错误代码和消息。标准库定义了两种全局错误类别:一般错误和系统错误。对于 POSIX 错误,std::generic_category()
函数返回一个error_category
对象,对于实现定义的错误,std::system_category()
返回一个error_category
。
一个std::error_code
将一个低级错误代码表示为一个与特定error_category
相关的整数。从errno
(无std::
前缀)中获取整数错误号,在 C 头文件<cerrno>
中声明。您可以如下构建一个error_code
对象:
auto ec{ std::error_code(errno, std::system_category()) };
但是如果您只需要相关的文本消息,错误类别会使用message()
成员函数直接返回它:
std::cerr << std::system_category().message(errno) << '\n';
要抛出一个使用错误代码的异常,抛出system_error
异常。你可以传递一个error_code
对象或者传递一个errno
和一个error_category
作为参数给system_error
构造器。您还可以传递一个可选字符串,如文件名。清单 48-10 显示了一个愚蠢的程序试图打开一个不存在的文件,并在打开失败时抛出一个异常。
#include <cerrno>
import <fstream>;
import <iostream>;
import <string>;
import <system_error>;
std::size_t count_words(std::string const& filename)
{
std::ifstream file(filename);
if (not file)
throw std::system_error(errno, std::system_category(), filename);
std::size_t count{0};
std::string word;
while (file >> word)
++count;
return count;
}
int main()
{
try
{
std::cout << count_words("Not a Real File Name") << '\n';
}
catch (std::exception const& ex)
{
std::cerr << ex.what() << '\n';
}
}
Listing 48-10.Throwing system_error for a File-Open Error
非凡的建议
异常的基本机制很容易掌握,但是它们的正确使用却比较困难。应用程序程序员有三个不同的任务:捕捉异常、抛出异常和避免异常。
您应该编写程序来捕捉所有异常,甚至是意外的异常。一种方法是让你的main
程序在整个程序体中有一个主try
语句。在程序中,您可以使用有针对性的try
语句来捕捉特定的异常。离异常源越近,拥有的上下文信息就越多,就越能改善问题,或者至少为用户提供更多有用的信息。
这个最外层的try
语句捕捉其他语句遗漏的任何异常。这是在程序突然终止之前给出一个连贯且有用的错误信息的最后尝试。至少,告诉用户程序由于意外的异常而终止。
在事件驱动的程序中,比如 GUI 应用程序,异常更成问题。最外层的try
语句关闭程序,关闭所有窗口。大多数事件处理程序应该有自己的try
语句来处理特定菜单选择、击键事件等的异常。
在程序体中,避免异常比捕捉异常更好。使用at
成员函数来访问 vector 的元素,但是您应该编写代码,以便确信索引总是有效的。索引和长度异常是程序员出错的迹象。
编写低级代码时,对于大多数不应该发生的错误情况或者反映程序员错误的错误情况,抛出异常。有些错误情况特别危险。例如,在rational
类中,在reduce()
返回后,分母不应该为零或负数。如果当分母确实为零或负数时出现一个条件,则程序的内部状态是损坏的。如果程序试图正常关闭,保存所有文件,等等,它可能会在文件中写入错误的数据。最好立即终止并依靠最新的备份副本,这是您的程序在其状态已知良好时制作的。对于这种紧急情况,使用断言,而不是异常。
理想情况下,您的代码应该验证用户输入,检查向量索引,并确保在调用函数之前所有函数的所有参数都是有效的。如果有任何东西是无效的,你的程序可以用一个清晰、直接的信息告诉用户,并完全避免异常。当您的检查失败或您忘记检查某些条件时,异常是一个安全网。
一般来说,库应该抛出异常,而不是捕捉它们。应用程序更倾向于捕捉异常而不是抛出异常。随着程序变得越来越复杂,我将强调需要异常、抛出或捕捉的情况。
既然您已经知道了如何编写类、重载操作符和处理错误,那么在开始实现自己的全功能类之前,您只需要学习一些额外的操作符。下一篇文章回顾了一些熟悉的操作符,并介绍了一些新的操作符。
四十九、更多运算符
C++ 有很多运算符。很多很多。到目前为止,我已经介绍了大多数程序需要的基本操作符:算术、比较、赋值、下标和函数调用。现在是时候介绍更多了:额外的赋值操作符、条件操作符(就像在表达式中间有一个if
语句)和逗号操作符(最常用于for
循环)。
条件运算符
条件运算符是 C++ 运算符库中的一个唯一条目,是一个三元运算符,也就是说,一个采用三个操作数的运算符。
condition ? true-part : false-part
条件是一个布尔表达式。如果计算结果为真,整个表达式的结果就是真部分。如果条件为假,则结果为假部分。与if
语句一样,只评估一部分;跳过未被采用的分支。例如,以下语句是绝对安全的:
std::cout << (x == 0 ? 0 : y / x);
如果x
为零,则不计算y / x
表达式,并且永远不会被零除。条件运算符的优先级非常低,所以您经常会看到它写在括号内。条件表达式可以是赋值表达式的源。所以下面的表达式把 42 或 24 赋给了x
,这取决于test
是否为真。
x = test ? 42 : 24;
赋值表达式可以是条件表达式的真部分或假部分,即下面的表达式
x ? y = 1 : y = 2;
被解析为
x ? (y = 1) : (y = 2);
真部分和假部分是具有相同或兼容类型的表达式,也就是说,编译器可以自动将一种类型转换为另一种类型,确保整个条件表达式具有定义良好的类型。比如可以混合整数和浮点数;表达式结果是一个浮点数。如果x
为正数,以下语句将打印10.000000
:
std::cout << std::fixed << (x > 0 ? 10 : 42.24) << '\n';
不要使用条件运算符代替if
语句。如果可以选择,使用if
语句,因为语句几乎总是比条件表达式更容易阅读和理解。在if
语句不可行的情况下使用条件表达式。例如,在构造器中初始化数据成员不允许使用if
语句。虽然可以对复杂的条件使用成员函数,但也可以对简单的条件使用条件表达式。
例如,rational
类(最后一次出现在 Exploration 47 )将分子和分母作为构造器的参数。这个类确保它的分母总是正的。如果分母为负,则对分子和分母求反。在过去的探索中,我给reduce()
成员函数加载了额外的职责,比如检查一个零分母和一个负分母,以反转分子和分母的符号。这种设计的优点是集中了将有理数转换成标准形式所需的所有代码。另一种设计是分离责任,让构造器在调用reduce()
之前检查分母。如果分母为零,构造器抛出异常;如果分母为负,则构造器对分子和分母求反。这种另类的设计让reduce()
更简单,简单的函数比复杂的函数更不容易出错。清单 49-1 展示了如何使用条件操作符来实现这一点。
/// Construct a rational object from a numerator and a denominator.
/// If the denominator is zero, throw zero_denominator. If the denominator
/// is negative, normalize the value by negating the numerator and denominator.
/// @post denominator_ > 0
/// @throws zero_denominator
rational::rational(int num, int den)
: numerator_{den < 0 ? -num : num},
denominator_{den == 0 ? throw zero_denominator("0 denominator") :
(den < 0 ? -den : den)}
{
reduce();
}
Listing 49-1.Using Conditional Expressions in a Constructor’s Initializer
一个throw
表达式有类型void
,但是编译器知道它不返回,所以你可以把它作为条件表达式的一部分(或者两部分)来使用。整体表达式的类型是非抛出部分的类型(或者是void
,如果两部分都抛出异常)。
换句话说,如果den
是零,表达式的真部分抛出异常。如果条件为假,则执行假部分,这是另一个条件表达式,它评估den
的绝对值。分子的初始化器也测试den
,如果为负,它也否定分子。
像我一样,您可能会发现使用条件表达式会使代码更难阅读。条件运算符在 C++ 程序中被广泛使用,所以你必须习惯阅读它。如果您认为条件表达式太复杂,编写一个单独的私有成员函数来完成这项工作,并通过调用该函数来初始化成员,如清单 49-2 所示。
/// Construct a rational object from a numerator and a denominator.
/// If the denominator is zero, throw zero_denominator. If the denominator
/// is negative, normalize the value by negating the numerator and denominator.
/// @post denominator_ > 0
/// @throws zero_denominator
rational::rational(int num, int den)
: numerator_{den < 0 ? -num : num}, denominator_{init_denominator(den)}
{
reduce();
}
/// Return an initial value for the denominator_ member. This function is used
/// only in a constructor's initializer list.
int rational::init_denominator(int den)
{
if (den == 0)
throw zero_denominator("0 denominator");
else if (den < 0)
return -den;
else
return den;
}
Listing 49-2.Using a Function and Conditional Statements Instead of Conditional Expressions
当编写新代码时,使用您最喜欢的技术,但是要习惯于阅读两种编程风格。
短路运算符
C++ 允许你重载and
和or
操作符,但是你必须抵制诱惑。通过重载这些操作符,您就失去了它们的一个主要优点:短路。
回想一下 Exploration 12 中的内容,如果不需要的话,and
和or
运算符不会计算它们的右操作数。对于内置的操作符来说是这样,但是如果你重载它们就不是这样了。当你重载布尔操作符时,它们就变成了普通函数,C++ 总是在调用函数之前计算函数参数。因此,重载的and
和or
操作符的行为与内置操作符不同,这种差异使得它们的用处大大降低。
Tip
不要支配and
和or
操作符。
逗点算符
逗号(,
)有很多作用:它分隔函数调用中的参数、函数声明中的参数、声明中的声明符以及构造器的初始化列表中的初始化符。在所有这些情况下,逗号都是一个标点符号,也就是说,它是语法的一部分,只用来表示一个事物(论元、声明符等)在哪里。)结束,另一件事开始。它本身也是一个运算符,这是同一个符号的完全不同的用法。逗号作为运算符分隔两个表达式;它导致左边的操作数被求值,然后右边的操作数被求值,这成为整个表达式的结果。左侧操作数的值被忽略。
乍一看,这个操作符似乎有点没有意义。毕竟,写作的目的是什么,比如说,
x = 1 + 2, y = x + 3, z = y + 4
代替
x = 1 + 2;
y = x + 3;
z = y + 4;
逗号运算符并不意味着可以代替编写单独的语句。然而,有一种情况,当多个语句是不可能的,但是多个表达式必须被求值。我说的不是别人,正是for
循环。
假设您想要实现基于迭代器的search
算法。实现一个完全通用的算法需要一些你还没有学过的技术,但是你可以编写这个函数,使它可以使用迭代器,但是不需要验证它的参数是否是正确的迭代器。基本思想很简单,search
遍历搜索范围,试图找到与匹配范围中的元素相等的元素序列。它一次遍历搜索范围中的一个元素,测试是否从该元素开始匹配。如果是,它将返回一个指向匹配起点的迭代器。如果没有找到匹配,search
返回结束迭代器。若要检查匹配,请使用嵌套循环来比较两个范围中的连续元素。清单 49-3 展示了实现这个功能的一种方法。
auto search(auto first1, auto last1, auto first2, auto last2)
{
// s1 is the size of the untested portion of the first range
// s2 is the size of the second range
// End the search when s2 > s1 because a match is impossible if the
// remaining portion of the search range is smaller than the test range.
// Each iteration of the outer loop shrinks the search range by one,
// and advances the first1 iterator. The inner loop searches
// for a match starting at first1.
for (auto s1{last1-first1}, s2{last2-first2}; s2 <= s1; --s1, ++first1)
{
// Is there a match starting at first1?
auto f2{first2};
for (auto f1{first1};
f1 != last1 and f2 != last2 and *f1 == *f2;
++f1, ++f2)
{
// The subsequence matches so far, so keep checking.
// All the work is done in the loop header, so the body is empty.
}
if (f2 == last2)
return first1; // match starts at first1
}
// no match
return last1;
}
Listing 49-3.Searching for a Matching Subrange Using Iterators
粗体行演示了逗号运算符。第一个for
循环的初始化部分不调用逗号运算符。声明中的逗号只是声明符之间的分隔符。逗号运算符出现在循环的后迭代部分。因为for
循环的后迭代部分是一个表达式,所以不能使用多个语句来递增多个对象。相反,你必须在一个表达式中完成,因此需要逗号操作符。
另一方面,一些程序员喜欢避免使用逗号操作符,因为生成的代码可能难以阅读。重写清单 49-3 以便它不使用逗号运算符。你更喜欢哪个版本的功能? ________________ 清单 49-4 显示了我的不带逗号运算符的search
版本。
auto search(auto first1, auto last1, auto first2, auto last2)
{
// s1 is the size of the untested portion of the first range
// s2 is the size of the second range
// End the search when s2 > s1 because a match is impossible if the
// remaining portion of the search range is smaller than the test range.
// Each iteration of the outer loop shrinks the search range by one,
// and advances the first1 iterator. The inner loop searches
// for a match starting at first1.
for (auto s1{last1-first1}, s2{last2-first2}; s2 <= s1; --s1)
{
// Is there a match starting at first1?
auto f2{first2};
for (auto f1{first1}; f1 != last1 and f2 != last2 and *f1 == *f2; )
{
++f1;
++f2;
}
if (f2 == last2)
return first1; // match starts at first1
++first1;
}
// no match
return last1;
}
Listing 49-4.The search Function Without Using the Comma Operator
逗号运算符的优先级很低,甚至低于赋值运算符和条件运算符。例如,如果循环必须使对象前进 2 步,则可以使用带有逗号运算符的赋值表达式。
for (int i{0}, j{size-1}; i < j; i += 2, j -= 2) do_something(i, j);
顺便说一下,C++ 允许你重载逗号操作符,但是你不应该利用这个特性。逗号非常基本,C++ 程序员很快就掌握了它的标准用法。如果逗号没有它通常的含义,当你的代码的读者试图理解它时,他们会感到困惑、迷惑和困难。
算术赋值运算符
除了常见的算术运算符,C++ 还有将算术和赋值结合起来的赋值运算符:+=
、-=
、*=
、/=
、%=
。赋值操作符x += y
是x = x + y
的简写,这同样适用于其他特殊的赋值操作符。因此,如果x
具有数字类型,以下三个表达式都是等价的:
x = x + 1;
x += 1;
++x;
特殊赋值操作符的优点是x
只计算一次,如果x
是一个复杂的表达式,这将是一个很好的选择。如果data
有类型std::vector<int>
,你觉得下面两个等价表达中哪个更容易阅读和理解?
data.at(data.size() / 2) = data.at(data.size() / 2) + 10;
data.at(data.size() / 2) += 10;
清单 49-5 显示了rational
类的*=
的一个示例实现。
rational const& rational::operator*=(rational const& rhs)
{
numerator_ *= rhs.numerator();
denominator_ *= rhs.denominator();
reduce();
return *this
;
}
Listing 49-5.Implementing the Multiplication Assignment Operator
operator*=
的返回类型是引用rational&
。返回值是*this
。尽管编译器允许您使用任何返回类型和值,但约定是赋值运算符返回对对象的引用,即左值。即使你的代码从来不使用返回值,但很多程序员使用赋值的结果,所以不要用void
作为返回类型。另一方面,给赋值结果赋值会导致疯狂,所以返回一个 const lvalue 很有意义。
rational r;
while ((r += rational{1,10}) != 2) do_something(r);
通常,实现算术运算符,比如+
,最简单的方法是首先实现相应的赋值运算符。然后根据赋值操作符实现自由操作符,如清单 49-6 中的rational
类所示。
rational operator*(rational const& lhs, rational const& rhs)
{
rational result{lhs};
result *= rhs;
return result;
}
Listing 49-6.Reimplementing Multiplication in Terms of an Assignment Operator
实现了 /=
、 +=
、 -=
类的运算符 rational
。你可以用很多方式实现这些操作符。我建议将算术逻辑放在赋值操作符中,并重新实现/
、+
和-
操作符来使用赋值操作符,就像我对乘法操作符所做的那样。我的解决方案出现在清单 49-7 中。
rational const& rational::operator+=(rational const& rhs)
{
numerator_ = numerator() * rhs.denominator() + rhs.numerator() * denominator();
denominator_ *= rhs.denominator();
reduce();
return *this;
}
rational const& rational::operator-=(rational const& rhs)
{
numerator_ = numerator() * rhs.denominator() - rhs.numerator() * denominator();
denominator_ *= rhs.denominator();
reduce();
return *this;
}
rational const& rational::operator/=(rational const& rhs)
{
if (rhs.numerator() == 0)
throw zero_denominator{"divide by zero"};
numerator_ *= rhs.denominator();
denominator_ *= rhs.numerator();
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
reduce();
return *this;
}
Listing 49-7.Other Arithmetic Assignment Operators
因为reduce()
不再检查负分母,任何可能将分母变为负的函数都必须检查。因为分母总是正的,所以你知道operator+=
和operator-=
不会导致分母变成负的。只有operator/=
引入了那种可能性,所以只有那个函数需要检查。
递增和递减
让我们给rational
类添加递增(++
)和递减(--
)操作符。因为这些操作符修改对象,所以我建议将它们实现为成员函数,尽管 C++ 也允许使用自由函数。为类 rational
**实现前缀递增运算符。**在清单 49-8 中比较你的函数和我的函数。
rational const& rational::operator++()
{
numerator_ += denominator_;
return *this;
}
Listing 49-8.The Prefix Increment Operator for rational
我相信您可以在没有额外帮助的情况下实现减量操作符。像算术赋值操作符一样,前缀operator++
返回对象作为引用。
这就剩下后缀运算符了。实现操作符的主体很容易,只需要一行额外的代码。但是,您必须注意返回类型。后缀运算符不能简单地返回*this
,因为它们返回对象的原始值,而不是它的新值。因此,这些运算符不能返回引用。相反,它们必须返回一个普通的右值。
但是如何声明函数呢?一个类不能有两个名称(operator++
)和参数相同的独立函数。不知何故,你需要一种方法告诉编译器operator++
的一个实现是前缀,另一个是后缀。
解决方案是,当编译器调用自定义后缀递增或递减运算符时,它将整数0
作为额外的参数传递。后缀运算符不需要这个额外参数的值;它只是一个占位符,用来区分前缀和后缀。
因此,当您用一个类型为int
的额外参数声明operator++
时,您是在声明后缀运算符。声明运算符时,省略额外参数的名称。这告诉编译器,函数不使用参数,所以编译器不会用关于未使用函数参数的消息来打扰你。为 rational
实现后缀递增和递减运算符。清单 49-9 显示了我的解决方案。
rational rational::operator++(int)
{
rational result{*this};
numerator_ += denominator_;
return result;
}
rational rational::operator--(int)
{
rational result{*this};
numerator_ -= denominator_;
return result;
}
Listing 49-9.Postfix Increment and Decrement Operators
一旦我们的修复项目尘埃落定,请看清单 49-10 中新的、改进的rational
类定义。
export module rat;
import <iostream>;
import <stdexcept>;
/// Represent a rational number (fraction) as a numerator and denominator.
export class rational
{
public:
class zero_denominator : public std::logic_error
{
public:
using std::logic_error::logic_error;
};
rational() noexcept : rational{0} {}
rational(int num) noexcept : numerator_{num}, denominator_{1} {}
rational(int num, int den);
rational(double r);
int numerator() const noexcept { return numerator_; }
int denominator() const noexcept { return denominator_; }
float as_float() const;
double as_double() const;
long double as_long_double() const;
// optimization to avoid an unneeded call to reduce()
rational const& operator=(int) noexcept;
rational const& operator+=(rational const& rhs);
rational const& operator-=(rational const& rhs);
rational const& operator*=(rational const& rhs);
rational const& operator/=(rational const& rhs);
rational const& operator++();
rational const& operator--();
rational operator++(int);
rational operator--(int);
private:
/// Reduce the numerator and denominator by their GCD.
void reduce();
/// Reduce the numerator and denominator, and normalize the signs of both,
/// that is, ensure denominator is not negative.
void normalize();
/// Return an initial value for denominator_. Throw a zero_denominator
/// exception if @p den is zero. Always return a positive number.
int init_denominator(int den);
int numerator_;
int denominator_;
};
export rational abs(rational const& r);
export rational operator-(rational const& r);
export rational operator+(rational const& lhs, rational const& rhs);
export rational operator-(rational const& lhs, rational const& rhs);
export rational operator*(rational const& lhs, rational const& rhs);
export rational operator/(rational const& lhs, rational const& rhs);
export bool operator==(rational const& a, rational const& b);
export bool operator<(rational const& a, rational const& b);
export inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
export inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
export inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
export inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
export std::istream& operator>>(std::istream& in, rational& rat);
export std::ostream& operator<<(std::ostream& out, rational const& rat);
Listing 49-10.The rational Class Definition
接下来的探索是你的第二个项目。现在,您已经了解了类、继承、运算符重载和异常,您已经准备好处理一些重要的 C++ 编码了。
五十、项目 2:定点数
项目 2 的任务是实现一个简单的定点数类。类使用整数类型表示定点数。小数点后的位数是一个固定的常量,四。例如,将数字 3.1415 表示为整数 31415,将 3.14 表示为 31400。您必须重载算术、比较和 I/O 运算符来维护定点虚构。
fixed
类
给类命名fixed
。它应该有以下公共成员:
value_type
基础整数类型的类型别名,如int
或long
。通过在整个fixed
类中使用value_type
,只需改变value_type
的声明,就可以轻松地在int
和long
之间切换。
places
A static const int
等于 4,或者小数点后的位数。通过使用命名常量而不是硬编码值 4,您可以很容易地在将来将该值更改为 2 或其他值。
places10
一个static const int
等于 places10
,或者为定点值的比例因子。用内部整数除以places10
得到真值。用一个数字乘以places10
将它缩放成一个整数,这个整数被fixed
对象存储在内部。
fixed()
默认构造器。
固定(值类型整数,值类型分数)
从整数部分和小数部分生成定点值的构造器。例如,要构造定点值 10.0020,请使用fixed{10, 20}
。
如果fraction < 0
抛出std::invalid_argument
。如果是fraction >= places10
,那么构造器应该丢弃右边的数字,舍入结果。比如fixed{3, 14159} == fixed{3, 1416}
和fixed{31, 415926} == fixed{31, 4159}
。
fixed(double val)
从浮点数生成定点值的构造器。将分数四舍五入,去掉多余的数字。由此,fixed{12.3456789} == fixed{12, 3456789} == fixed{12, 3457}
。
实现算术运算符、算术赋值运算符、比较运算符和 I/O 运算符。不要担心溢出。读取定点数时尽可能检查错误。一定要处理不带小数点的整数(42
)和带太多小数点的值(3.14159
)。
实现一个成员函数,将定点值转换为std::string
。
to_string()
将该值转换为字符串表示形式;比如 3.1416 变成"3.1416"
,–21 变成"-21.0000"
。
转换成整数意味着丢弃信息。为了让用户非常清楚,调用函数round()
,强调定点值必须四舍五入为整数。
round()
四舍五入到最接近的整数。如果小数部分正好是 5000,则四舍五入到最接近的偶数(银行家四舍五入)。一定要处理负数和正数。
其他有用的成员函数让您可以访问原始值(有利于调试、实现附加操作等)。)或定点值的部分:整数部分和小数部分。
integer()
只返回整数部分,不返回小数部分。
fraction()
只返回小数部分,不返回整数部分。分数部分始终在[ 0
,places10
]范围内。
在一个名为fixed
的模块中实现fixed
类。您可以决定是编写单独的接口和实现模块,还是编写单个模块文件。决定哪些成员函数应该是内联的(如果有的话),并确保在模块接口中定义所有的内联函数。完成后,仔细检查您的解决方案并进行一些测试,将您的结果与我的结果进行比较,您可以从本书的网站上下载。
如果你需要帮助测试你的代码,试着将你的fixed
模块与清单 50-1 中的测试程序链接起来。测试程序使用在test
模块中声明的test
和test_equal
功能。细节超出了本书的范围。用一个布尔参数调用test
。如果参数为真,则测试通过。否则,测试失败,并且test
打印消息。test_equal
函数接受两个参数,如果它们不相等,则打印一条消息。因此,如果程序没有产生输出,所有测试都通过了。
import <iostream>;
import <sstream>;
import <stdexcept>;
import test;
import fixed;
int main()
{
fixed f1{};
test_equal(f1.value(), 0);
test_equal(f1.to_string(), "0.0000");
fixed f2{1};
test_equal(f2.value(), 10000);
test_equal(f2.to_string(), "1.0000");
fixed f3{3, 14162};
test_equal(f3.value(), 31416);
fixed f4{2, 14159265};
test_equal(f4.value(), 21416);
test_equal(f2 + f4, f1 + f3);
test(f2 + f4 <= f1 + f3);
test(f2 + f4 >= f1 + f3);
test(f1 < f2);
test(f1 <= f2);
test(f1 != f2);
test(f2 > f1);
test(f2 >= f1);
test(f2 != f1);
test_equal(f2 + f4, f3 - f1);
test_equal(f2 * f3, f3);
test_equal(f3 / f2, f3);
f4 += f2;
test_equal(f3, f4);
f4 -= f1;
test_equal(f3, f4);
f4 *= f2;
test_equal(f3, f4);
f4 /= f2;
test_equal(f3, f4);
test_equal(-f4, f1 - f4);
test_equal(-(-f4), f4);
--f4;
test_equal(f4 + 1, f3);
f4--;
test_equal(f4 + 2, f3);
++f4;
test_equal(f4 + 1, f3);
f4++;
test_equal(f4, f3);
++f3;
test_equal(++f4, f3);
test_equal(f4--, f3);
test_equal(f4++, --f3);
test_equal(--f4, f3);
test_equal(f4 / f3, f2);
test_equal(f4 - f3, f1);
test_equal(f4.to_string(), "3.1416");
test_equal(f4.integer(), 3);
f4 += fixed{0,4584};
test_equal(f4, 3.6);
test_equal(f4.integer(), 3);
test_equal(f4.round(), 4);
test_equal(f3.integer(), 3);
test_equal((-f3).integer(), -3);
test_equal(f3.fraction(), 1416);
test_equal((-f3).fraction(), 1416);
test_equal(fixed{7,4999}.round(), 7);
test_equal(fixed{7,5000}.round(), 8);
test_equal(fixed{7,5001}.round(), 8);
test_equal(fixed{7,4999}.round(), 7);
test_equal(fixed{8,5000}.round(), 8);
test_equal(fixed{8,5001}.round(), 9);
test_equal(fixed{123,2345500}, fixed(123,2346));
test_equal(fixed{123,2345501}, fixed(123,2346));
test_equal(fixed{123,2345499}, fixed(123,2345));
test_equal(fixed{123,2346500}, fixed(123,2346));
test_equal(fixed{123,2346501}, fixed(123,2347));
test_equal(fixed{123,2346499}, fixed(123,2346));
test_equal(fixed{123,2346400}, fixed(123,2346));
test_equal(fixed{123,2346600}, fixed(123,2347));
test_equal(fixed{-7,4999}.round(), -7);
test_equal(fixed{-7,5000}.round(), -8);
test_equal(fixed{-7,5001}.round(), -8);
test_equal(fixed{-7,4999}.round(), -7);
test_equal(fixed{-8,5000}.round(), -8);
test_equal(fixed{-8,5001}.round(), -9);
test_equal(fixed{-3.14159265}.value(), -31416);
test_equal(fixed{123,456789}.value(), 1234568);
test_equal(fixed{123,4}.value(), 1230004);
test_equal(fixed{-10,1111}.value(), -101111);
std::ostringstream out{};
out << f3 << " 3.14159265 " << fixed(-10,12) << " 3 421.4 end";
fixed f5{};
std::istringstream in{out.str()};
test(in >> f5);
test_equal(f5, f3);
test(in >> f5);
test_equal(f5, f3);
test(in >> f5);
test_equal(f5.value(), -100012);
test(in >> f5);
test_equal(f5.value(), 30000);
test(in >> f5);
test_equal(f5.value(), 4214000);
test(not (in >> f5));
test_equal(fixed{31.4159265}, fixed{31, 4159});
test_equal(fixed{31.41595}, fixed{31, 4160});
bool okay{false};
try {
fixed f6{1, -1};
} catch (std::invalid_argument const&) {
okay = true;
} catch (...) {
}
test(okay);
test_exit();
}
Listing 50-1.Testing the fixed Class
如果你需要一个提示,我实现了fixed
以便它存储一个单一的整数,并从右开始隐含小数点位置places10
。因此,我将值 1 存储为 10000。加减法很容易。当乘或除时,你必须缩放结果。(更好的方法是在乘法之前缩放操作数,这可以避免一些溢出的情况,但是您必须小心不要损失精度。)
五十一、函数模板
您在 Exploration 25 中看到,重载的魔力让 C++ 实现了绝对值函数的改进接口。取而代之的是三个不同的名字(abs
、labs
和fabs
),C++ 对所有三个函数都有一个名字。重载对需要调用abs
函数的程序员有帮助,但对实现者帮助不大,实现者仍然必须编写三个外观和行为都相同的独立函数。如果库作者能写一次abs
函数而不是三次,那不是很好吗?毕竟,这三个实现可能是相同的,只是返回类型和参数类型不同。本文介绍了这种称为泛型编程的编程风格。
通用函数
有时,您希望为整数和浮点类型提供重载函数,但实现本质上是相同的。绝对值就是一个例子;对于任何类型T
,函数看起来都是一样的(我使用名称absval
,以避免与标准库的abs
混淆或冲突),如清单 51-1 所示。
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
Listing 51-1.Writing an Absolute Value Function
将T
替换为int
,将T
替换为double
,或者使用任何其他数字类型。你甚至可以用rational
代替T
,而absval
功能仍然按照你期望的方式工作。那么,为什么要浪费宝贵的时间编写、重写、再重写同一个函数呢?通过对函数定义的简单添加,您可以将函数转换为通用函数,也就是说,可以与任何合适的类型T
一起工作的函数,这可以在清单 51-2 中看到。
template<class T>
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
Listing 51-2.Writing a Function Template
第一行是关键。template
关键字意味着后面是一个模板,在这种情况下,是一个函数模板定义。尖括号分隔了以逗号分隔的模板参数列表。函数模板是根据参数类型T
创建函数的模式。在函数模板定义中,T
代表一个类型,可能是任何类型。absval
函数的调用者决定了将替代T
的模板参数。
定义函数模板时,编译器会记住该模板,但不会生成任何代码。编译器会一直等到你使用函数模板,然后生成一个真正的函数。可以想象一下,编译器获取模板的源文本,用模板参数(如int
)替换模板参数T
,然后编译结果文本。下一节将告诉您更多关于如何使用函数模板的内容。
使用函数模板
使用函数模板很容易,至少在大多数情况下是如此。只需调用absval
函数,编译器会根据函数参数类型自动确定模板参数。你可能需要一点时间来熟悉模板参数和模板实参的概念,它们与函数形参和函数实参有很大的不同。
在absval
的情况下,模板参数是T
,模板实参必须是类型。不能将类型作为函数参数传递,但模板是不同的。你在程序中并没有真正“传递”任何东西。模板魔术发生在编译时。编译器看到了absval
的模板定义,然后看到了absval
函数模板的调用。编译器检查函数参数的类型,并根据函数参数的类型确定模板参数。编译器用模板参数替换T
,并生成一个新的absval
函数实例,为模板参数类型定制。因此,在下面的例子中,编译器发现x
具有类型int
,所以它用int
替换T
。
int x{-42};
int y{absval(x)};
编译器生成一个函数,就像库实现者编写了以下代码一样:
int absval(int x)
{
if (x < 0)
return -x;
else
return x;
}
后来,在同一个程序中,也许你在一个rational
对象上调用absval
:
rational r{-420, 10};
rational s{absval(r)};
编译器生成一个新的absval
实例:
rational absval(rational x)
{
if (x < 0)
return -x;
else
return x;
}
在这个新的absval
实例中,<
操作符是接受rational
参数的重载操作符。求反操作符也是一个定制操作符,它接受一个rational
参数。换句话说,当编译器生成一个absval
的实例时,它通过编译源代码来完成,就像模板作者写的那样。
编写一个包含absval
函数模板定义和一些测试代码的示例程序,用各种参数类型调用absval
。让自己相信函数模板确实有效。在清单 51-3 中将你的测试程序与我的进行比较。
import <iostream>;
import rational; // Listing 49-10
template<class T>
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
int main()
{
std::cout << absval(-42) << '\n';
std::cout << absval(-4.2) << '\n';
std::cout << absval(42) << '\n';
std::cout << absval(4.2) << '\n';
std::cout << absval(-42L) << '\n';
std::cout << absval(rational{42, 5}) << '\n';
std::cout << absval(rational{-42, 5}) << '\n';
std::cout << absval(rational{42, -5}) << '\n';
}
Listing 51-3.Testing the absval Function Template
编写函数模板
编写函数模板比编写普通函数更难。当你写一个像absval
这样的模板时,问题是你不知道T
实际上会是什么类型。所以,函数必须是通用的。编译器会阻止你使用某些类型的T
。模板体使用T
的方式隐含了它的限制。
特别是,absval
对T
施加了以下限制:
-
T
*必须是可复制的。*这意味着你必须能够复制一个类型为T
的对象,这样参数可以传递给函数,结果可以返回。如果T
是一个类类型,那么这个类必须有一个可访问的复制构造器,也就是说,复制构造器不能是私有的。 -
T
必须与0
可比使用<
运算符。您可能会重载<
操作符,或者编译器会将0
转换为T
或将T
转换为int
。 -
必须为
T
类型的操作数定义一元operator-
。结果类型必须是T
或者编译器可以自动转换成T
的类型。
内置的数值类型都符合这些要求。rational
类型也符合这些要求,因为它支持自定义操作符。举例来说,string
类型没有,因为当右边的操作数是整数时,它缺少比较运算符,并且它缺少一元求反(-
)运算符。假设你试图在一个string
上呼叫absval
。
std::string test{"-42"};
std::cout << absval(test) << '\n';
你认为会发生什么?
试试看。到底发生了什么?
编译器抱怨缺少std::string
的比较和求反运算符。使用模板时,传递有用的错误消息的一个困难是,是给出使用模板的行号,还是给出模板定义中的行号。有时候,你会两者兼得。有时,除非您尝试使用模板,否则编译器无法报告模板定义中的错误。它可以立即报告其他错误。仔细阅读清单 51-4 。
template<class T>
T add(T lhs, T rhs)
{
return lhs(rhs);
}
int main()
{
}
Listing 51-4.Mystery Function Template
错误是什么?
你的编译器会报告它吗?
因为编译器不知道类型T
,所以无法判断lhs(rhs)
是什么意思。可以定义一个表达式有效的类型,但是这可能与add
的函数名不匹配。我们知道我们想要对T
使用数值类型,所以lhs(rhs)
是愚蠢的。毕竟3(4)
是什么意思?
如何让你的编译器报告错误?
添加一行代码来使用模板。例如,将此添加到main
:
return add(0, 0);
现在,每个编译器都会在模板定义中报告不是真正的函数调用表达式。
模板参数
每当你在一个 C++ 程序中看到T
,很可能你正在看一个模板。向后查看源文件,直到找到模板头,也就是以template
关键字开始的声明部分。这就是你应该找到模板参数的地方。使用T
作为模板参数名仅仅是一个惯例,但是它的使用几乎是普遍的。使用class
来声明T
可能看起来有点奇怪,尤其是因为您已经看到了几个模板参数实际上不是一个类的例子。
有些程序员不使用class
来声明模板参数类型,而是使用另一个关键字typename
,在这个上下文中意思是一样的。typename
相对于class
的优势在于它避免了对非类类型的任何混淆。缺点是typename
在模板上下文中有不止一种用法,这会在更复杂的模板定义中混淆人类读者。学会阅读这两种风格,但我在编写自己的模板时更喜欢使用class
,而且在大多数 C++ 代码中class
比typename
出现得更频繁。
有时候,你会看到比T
更具体的参数名。如果模板有多个参数,那么每个参数都必须有一个惟一的名称,所以您肯定会看到除了T
之外的名称。例如,std::ranges::copy
算法是一个带有两个模板参数的函数模板:输入范围类型和输出迭代器类型。因此,copy
的定义可能类似于清单 51-5 。
template<class InputRange, class OutputIterator>
OutputIterator copy(InputRange range, OutputIterator output)
{
for (auto const& item : range)
*output++ = item;
return output;
}
Listing 51-5.One Way to Implement the copy Algorithm
很简单,不是吗?(真正的copy
函数更复杂,需要检查有效的参数并对某些类型进行优化。然而,在复杂性之下,可能是一个看起来像清单 51-5 的函数,尽管有不同的参数名。)
使用copy
算法时,编译器根据函数参数类型确定InputRange
和OutputIterator
的值。正如您在absval
中看到的,函数对模板参数的要求都是隐式的。因为InputRange
必须是一个范围,std::ranges::begin(range)
必须返回起始迭代器,std::ranges::end(range)
必须返回哨兵。起始迭代器必须满足输入迭代器的要求,即运算符*
返回一个项,operator++
推进迭代器,迭代器必须与 sentinel 具有可比性。OutputIterator
也必须以输出迭代器的方式实现*
和++
。
写一个 find
**算法的简单实现。**这个算法的范围形式提出了一些棘手的问题,所以让我们实现函数的迭代器形式。(好奇的话想一下find()
的区间版。它的返回类型是什么?模板参数是范围类型,std::ranges::begin()
返回一个迭代器,这是find()
想要的返回类型。但是如果没有找到这个值,find()
必须返回std::ranges::end()
的标记值,即使它有不同的类型。所以find()
必须将 sentinel 值转换成迭代器类型。在接下来的探索中,您将了解更多关于模板的知识,这将帮助您掌握这些问题。现在我们回到find()
的迭代器形式。)
模板有两个参数:InputIterator
和T
。该函数有三个参数。前两个类型为InputIterator
,指定了要搜索的范围。第三个参数的类型是T
,是要搜索的值。将您的解决方案与清单 51-6 进行比较。
template<class InputIterator, class T>
InputIterator find(InputIterator start, InputIterator end, T value)
{
for ( ; start != end; ++start)
if (*start == value)
return start;
return end;
}
Listing 51-6.Implementing the find Algorithm
许多标准算法本质上都很简单。现代的实现经过了大量的优化,手动优化代码的本质就是这样,结果通常与原始代码几乎没有相似之处,并且优化后的代码可能更难阅读。尽管如此,简单性仍然保留在标准库的架构中,它广泛依赖于模板。
模板参数
当编译器自动从函数参数中推导出模板参数时,模板是最容易使用的。然而,它不能总是这样做,所以你可能必须明确地告诉编译器你想要什么。例如,min
、max
和mixmax
标准算法的简单形式采用单个模板参数。清单 51-7 显示了min
功能的一种可能实现,以供参考。
template<class T>
T min(T a, T b)
{
if (b < a)
return b;
else
return a;
}
Listing 51-7.The std::min Algorithm
如果两个参数类型相同,编译器可以推导出所需的类型,一切都正常了。
int x{10}, y{20}, z{std::min(x, y)};
另一方面,如果函数参数类型不同,编译器就不能判断模板参数使用哪种类型。
int x{10};
long y{20};
std::cout << std::min(x, y); // error
为什么会这样?假设您编写了自己的函数作为非模板。
long my_min(long a, long b)
{
if (b < a)
return b;
else
return a;
}
编译器可以通过将x
从int
转换为long
来处理my_min(x, y)
。但是,作为模板,编译器不执行任何自动类型转换。编译器无法理解您的想法,也不知道您希望模板参数具有第一个函数参数或第二个函数参数的类型,或者有时是第一个,有时是第二个。相反,编译器要求你确切地写出你的意思。在这种情况下,您可以通过将所需的类型括在尖括号中来告诉编译器模板参数使用什么类型。
int x{10};
long y{20};
std::cout << std::min<long>(x, y); // okay: compiler converts x to type long
如果模板有多个参数,请用逗号分隔参数。例如,清单 51-8 显示了input_sum
函数,它从标准输入中读取项目,并通过+=
操作符累加它们。累加器的类型可以不同于项目类型。因为函数参数中没有使用 item 和 accumulator 类型,所以编译器无法推断出参数参数,所以您必须显式地提供它们。
import <iostream>;
template<class T, class U>
U input_sum(std::istream& in)
{
T x{};
U sum{0};
while (in >> x)
sum += x;
return sum;
}
int main()
{
long sum{input_sum<int, long>(std::cin)};
std::cout << sum << '\n';
}
Listing 51-8.Multiple Template Arguments
写一个函数,isprime,做一个函数模板,这样就可以对int
、short
或者long
参数使用同一个函数模板。该函数确定其参数是否为质数,即只能被 1 及其自身整除的数。将您的解决方案与清单 51-9 中的我的解决方案进行比较。
template<class T>
bool isprime(T n)
{
if (n < 2)
return false;
else if (n <= 3)
return true;
else if (n % 2 == 0)
return false;
else
{
for (T test{3}, limit{n / 2}; test < limit; test += 2)
if (n % test == 0)
return false;
return true
;
}
}
Listing 51-9.The isprime Function Template
缩写函数模板
编写某些模板的一个更短的方法是使用auto
关键字作为函数参数类型,类似于它可以用来定义局部变量的方式。对任何函数参数类型使用auto
会将函数变成函数模板。每个auto
参数就像添加一个模板参数。例如,您可以使用auto
重写清单 51-5 ,如清单 51-10 所示。
auto copy(auto range, auto output)
{
for (auto const& item : range)
*output++ = item;
return output;
}
Listing 51-10.Another Way to Implement the copy Algorithm
返回类型也是auto
,它告诉编译器从函数中的return
语句确定返回类型。在这种情况下,返回类型与output
参数的类型相同,这正是我们想要的。
对于copy
(清单 51-6 )的迭代器形式,使用auto
要困难得多,因为每个auto
函数参数都有一个单独的模板参数作为其类型。强制两个参数具有相同的类型是普通函数模板最容易做到的。但是,当一个函数模板的每个函数参数都有一个单独的模板参数时,缩写形式可能是编写函数模板的一种简洁方式。
声明和定义
我似乎不能停止谈论声明和定义。模板给这个情节带来了另一个转折。使用模板时,规则会发生变化。在使用函数模板之前,编译器必须看到的不仅仅是一个声明。编译器通常需要完整的函数模板定义。换句话说,如果你在一个模块中定义一个模板,那么这个模块必须包含这个函数模板的主体。假设您想在多个项目中共享isprime
函数。通常,您会将函数声明放在一个模块中,比如说prime
,并且您可能想要一个单独的模块实现。
然而,当你将isprime
转换成一个函数模板时,你必须将定义放在模块接口中,这样编译器就可以从模板中创建具体的函数,比如说,为isprime<int>
或isprime<long>
创建函数。
成员函数模板
在探索 36 中,我们为rational
类编写了三个几乎相同的函数:to_long_double
、to_double
和to_float
。它们都做同样的事情:在转换成目标类型后,将分子除以分母。每当有多个函数以相同的方式使用相同的代码做相同的事情时,就有了一个候选模板,如下所示:
template<class T, class R>
T convert(R const& r)
{
return static_cast<T>(r.numerator()) / r.denominator();
}
与任何函数模板一样,R
上唯一的要求是类型为R
的对象具有名为numerator()
和denominator()
的成员函数,并且这些函数具有适用于operator/
的返回类型(可以重载)。要使用convert
函数,您必须提供目标类型T
,作为显式模板参数,但是您可以让编译器从函数参数中推导出R
:
rational r{42, 10};
double d{ convert<double>(r) };
您可以从最右边的参数开始,省略编译器可以推导出的模板参数。正如您在本文前面所看到的,如果编译器可以推导出所有的参数,那么您可以完全省去尖括号。
函数模板也可以是成员函数。您可能更喜欢使用成员函数模板,而不是将rational
对象作为参数传递,如下所示:
rational r{42, 10};
double d{ r.convert<double>() };
成员函数模板避免了与其他可能被命名为convert
的自由函数的冲突。但是它也限制了你的函数的效用。作为一个非成员函数(也称为自由函数),它适用于任何看起来像有分子和分母的有理数类型的类型。即使是你没见过的类型,只要满足 convert()函数的基本限制,就可以了。但是作为一个成员函数,它不可避免地与您的 rational 类型而不是其他人的类型联系在一起。您将会看到标准 C++ 库有许多免费的函数,这些函数被设计用来处理各种用户编写的类型。这是非常好的设计。
泛型编程是一种强大的技术,随着您在接下来的几篇文章中对它了解得越来越多,您将会看到这种编程范式是多么的有表现力和有用。
五十二、类模板
一个类可以是一个模板,这使得它的所有成员都是模板。本书中的每个程序都使用了类模板,因为标准库的大部分都依赖于模板:标准 I/O 流、字符串、向量和映射都是类模板。这个探索着眼于简单的类模板。
参数化类型
考虑一个简单的point
类,它存储一个x
和y
坐标。图形设备驱动程序可能使用int
作为成员类型。
class point {
public:
point(int x, int y) : x_{x}, y_{y} {}
int x() const { return x_; }
int y() const { return y_; }
private:
int x_, y_;
};
另一方面,一个演算工具可能更喜欢使用double
。
class point {
public:
point(double x, double y) : x_{x}, y_{y} {}
double x() const { return x_; }
double y() const { return y_; }
private:
double x_, y_;
};
想象一下给point
类添加更多的功能:计算两个point
对象之间的距离,将一个point
围绕另一个旋转某个角度,等等。您想出的功能越多,您必须在两个类中复制的代码就越多。
如果您可以编写一次point
类,并对这两种情况和其他还没有想到的情况使用这个定义,您的工作不是更简单吗?拯救模板。清单 52-1 显示了point
类模板。
template<class T>
class point {
public:
point(T x, T y) : x_{x}, y_{y} {}
T x() const { return x_; }
T y() const { return y_; }
/// Move to absolute coordinates (x, y).
void move_to(T x, T y);
/// Add (x, y) to current position.
void move_by(T x, T y);
private:
T x_, y_;
};
template<class T>
void point<T>::move_to(T x, T y)
{
x_ = x;
y_ = y;
}
Listing 52-1.The point Class Template
正如函数模板一样,template
关键字引入了一个类模板。类模板是一种创建类的模式,您可以通过提供模板参数来创建类,例如point<int>
。
类模板的成员函数本身就是函数模板,使用相同的模板参数,除了你提供模板参数给类,而不是函数,正如你在point<T>::move_to
函数中看到的。编写 move_by
**成员函数。**将您的解决方案与清单 52-2 进行比较。
template<class T>
void point<T>::move_by(T x, T y)
{
x_ += x;
y_ += y;
}
Listing 52-2.The move_by Member Function
每次使用不同的模板参数时,编译器都会用新的成员函数生成一个新的类实例。也就是说,point<int>::move_by
是一个函数,point<double>::move_by
是另一个函数,这正是如果您手工编写函数会发生的情况。如果两个不同的源文件都使用point<int>
,编译器和链接器确保它们共享同一个模板实例。
参数化 rational 类
一个简单的point
类很容易。更复杂的东西呢,比如rational
类?假设有人喜欢你的rational
类,但是想要更精确。您决定将分子和分母的类型从int
改为long
。然后有人抱怨说rational
占用了太多内存,并要求使用short
作为基本类型的版本。您可以复制三份源代码,分别用于类型short
、int
和long
。或者您可以定义一个类模板,如清单 52-3 中简化的rational
类模板所示。
template<class T>
class rational
{
public:
using value_type = T;
rational() : rational{0} {}
rational(value_type num) : numerator_{num}, denominator_{1} {}
rational(value_type num, value_type den);
void assign(value_type num, value_type den);
rational const& operator +=(rational const& rhs);
rational const& operator -=(rational const& rhs);
rational const& operator *=(rational const& rhs);
rational const& operator /=(rational const& rhs);
template<class U>
U convert()
const
{
return static_cast<U>(numerator()) / static_cast<U>(denominator());
}
value_type const& numerator() const { return numerator_; }
value_type const& denominator() const { return denominator_; }
private:
void reduce();
value_type numerator_;
value_type denominator_;
};
template<class T>
rational<T>::rational(value_type num, value_type den)
: numerator_{num}, denominator_{den}
{
reduce();
}
template<class T>
void rational<T>::assign(value_type num, value_type den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
template<class T>
bool operator==(rational<T> const& a, rational<T> const& b)
{
return a.numerator() == b.numerator() and
a.denominator() == b.denominator();
}
template<class T>
inline bool operator!=(rational<T> const& a, rational<T> const& b)
{
return not (a == b);
}
Listing 52-3.The rational Class Template
成员类型是一个有用的约定。许多使用模板参数作为某种从属类型的类模板在一个明确定义的名称下公开参数。例如,vector<char>::value_type
是其模板参数的成员类型,即char
。
看构造器的定义。当你在类模板之外定义一个成员时,你必须重复模板头。类型的全名包括模板参数,在本例中为rational<T>
。在类范围内,只使用类名,不使用模板参数。此外,一旦编译器看到完全限定的类名,它就知道它在类范围内,您也可以单独使用模板参数,这可以在参数声明中看到。在成员函数定义中,您可以调用任何其他成员函数并使用成员类型,例如value_type
。
因为名称T
已经被使用了,所以convert
成员函数(第 12 行)需要一个新名称作为它的模板参数。U
是一个常见的约定,只要你不做得太过分。多于两三个单字母参数,您开始需要更有意义的名称,只是为了帮助保持哪个参数与哪个模板匹配。
除了类模板本身,您还必须将所有支持 rational 类型的自由函数转换为函数模板。清单 52-3 通过只显示operator==
和operator!=
使事情变得简单。其他运算符的工作方式类似。
使用类模板
与函数模板不同,编译器不能推导出类模板的模板参数。这意味着您必须在尖括号内显式提供参数。
rational<short> zero{};
rational<int> pi1{355, 113};
rational<long> pi2{80143857L, 25510582L};
注意到什么熟悉的东西了吗?rational<int>
长得像vector<int>
吗?所有的集合类型,比如vector
和map
,都是类模板。标准库自始至终大量使用模板,在适当的时候,您会发现其他模板。
如果一个类模板有多个参数,用逗号分隔参数,如map<long, int>
所示。模板参数甚至可以是另一个模板,例如
std::vector<std::vector<int>> matrix;
从清单 52-3中的rational<>
开始,添加 I/O 操作符。(关于操作符的非模板版本,请参见清单 36-4。)编写一个简单的测试程序,读取rational
对象并将值回显到标准输出中,每行一个值。尝试将模板参数更改为不同的类型(short
、int
、long
)。您的测试程序可能看起来类似于清单 52-4 。
import <iostream>;
import rational;
int main()
{
rational<int> r{};
while (std::cin >> r)
std::cout << r << '\n';
}
Listing 52-4.Simple I/O Test of the rational Class Template
现在修改测试程序,只打印非零值。该程序看起来应该类似于清单 52-5 。
import <iostream>;
import rational;
int main()
{
static const rational<int> zero{};
rational<int> r{};
while (std::cin >> r)
if (r != zero)
std::cout << r << '\n';
}
Listing 52-5.Testing rational Comparison Operator
记住,使用旧的rational
类,编译器知道如何从整数构造一个rational
对象。因此,它可以将0
转换为rational(0)
,然后调用重载的==
操作符来比较两个 rational 对象。所以一切都很好。正确那么它为什么不起作用呢?
过载运算符
请记住,在前面的探索中,编译器不会为函数模板执行自动类型转换。这意味着编译器不会将一个int
转换成一个rational<int>
。为了解决这个问题,您必须添加一些额外的比较运算符,例如
template<class T> bool operator==(rational<T> const& lhs, T rhs);
template<class T> bool operator==(T lhs, rational<T> const& rhs);
template<class T> bool operator!=(rational<T> const& lhs, T rhs);
template<class T> bool operator!=(T lhs, rational<T> const& rhs);
对于所有的比较和算术运算符,以此类推。另一方面,你必须考虑这是否是你真正想要的。为了更好地理解这种方法的局限性,请继续尝试。你还不需要所有的比较操作符,只需要operator!=
,这样你就可以编译测试程序了。在添加了两个新的重载operator!=
函数之后,再次编译清单 52-5 ,以确保它能够工作。接下来,用模板参数long
编译测试程序。会发生什么?
编译器再一次抱怨它找不到任何适合!=
操作符的函数。问题是模板参数存在一个重载的!=
运算符,即类型long
,但是文字0
的类型是int
,而不是long
。您可以尝试通过为所有内置类型定义操作符来解决这个问题,但这很快就会失去控制。所以你的选择如下:
-
仅定义接受两个
rational
参数的运算符。强制调用者将参数转换成所需的rational
类型。 -
用三元组定义操作符:一个接受两个
rational
参数,另外两个混合了一个rational
和一个基本类型(T
)。 -
定义操作符来覆盖所有的基础—对于内置类型(
signed char
、char
、short
、int
、long
,加上一些我还没有覆盖的类型。因此,每个运算符需要 11 个函数。
您可能有兴趣了解 C++ 标准库如何解决这个问题。标准库中的类型中有一个类模板complex
,它代表一个复数。标准化委员会选择了第二种选择,即三个重载函数模板。
template<class T> bool operator==(complex<T> const& a, complex<T> const& b);
template<class T> bool operator==(complex<T> const& a, T const& b);
template<class T> bool operator==(T const& a, complex<T> const& b);
这个解决方案足够好,在本书的后面,您将学习一些技术来减少定义所有这些函数所涉及的工作量。
这个问题的另一个方面是字面上的0
。当你知道rational
的基本类型也是int
时,使用类型int
的字面量就可以了。如何在模板中表达一个通用的零?当测试零分母时,也会出现同样的问题。当你知道分母的类型是int
时,这就很容易了。使用模板时,您不知道模板的类型。回想一下清单 47-6,除法运算符检查零因子,在这种情况下抛出一个异常。如果你不知道类型T
,你怎么知道如何表示值零?你可以尝试使用文字0
并希望T
有一个合适的构造器(类型int
的单参数)。更好的解决方案是调用类型T
的默认构造器,如清单 52-6 所示。
template<class T>
rational<T> const& rational<T>::operator/=(rational const& rhs)
{
if (rhs.numerator() == T{})
throw zero_denominator("divide by zero");
numerator_ *= rhs.denominator();
denominator_ *= rhs.numerator();
if (denominator_ < T{})
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
reduce();
return *this;
}
Listing 52-6.Invoking a Default Constructor of a Template Parameter
如果类型T
是一个类类型,T{}
产生一个使用T
的默认构造器初始化的对象。如果T
是内置类型,T{}
的值为零(即0
、0.0
或false
)。在输入操作符中初始化局部变量有点复杂。
混合类型
如你所知,你可以给一个long
对象分配一个int
值,或者给分配一个值,反之亦然。因此,您应该能够为一个rational<long>
对象分配一个rational<int>
值,这似乎是合理的。试试看。编写一个简单的程序来执行混合基本类型的赋值。你的程序可能看起来有点像清单 52-7 ,但是许多其他程序同样合理。
import rational;
int main()
{
rational<int> little{};
rational<long> big{};
big = little;
}
Listing 52-7.Trying to Mix rational Base Types
当你编译你的程序时会发生什么?
新的rational
类模板的唯一赋值操作符是编译器的隐式操作符。它的参数类型是rational<T> const
,所以源表达式的基类型必须和赋值目标的基类型相同。使用成员函数模板可以很容易地解决这个问题。将以下声明添加到类模板中:
template<class U>
rational& operator=(rational<U> const& rhs);
在rational
类模板中,简单的名字rational
与rational<T>
意思相同。类的完整名称包括模板参数,因此构造器的正确名称是rational<T>
。因为rational
和rational<T>
意思相同,所以我可以在整个类模板定义中缩短构造器名和类型名的许多其他用法。但是赋值运算符的参数是rational<U>
。它使用了完全不同的模板参数。使用这个赋值操作符,您可以在一个赋值语句中自由混合不同的rational
类型。
写出赋值运算符的定义。不要担心将大值赋给小值可能会导致溢出。这是一个困难的问题,而且会分散对手头主要任务的注意力,主要任务是练习编写类模板和函数模板。将您的解决方案与清单 52-8 进行比较。
template<class T>
template<class U>
rational<T>& rational<T>::operator=(rational<U> const& rhs)
{
assign(rhs.numerator(), rhs.denominator());
return *this;
}
Listing 52-8.Defining the Assignment Operator Function Template
第一个模板头告诉编译器关于rational
类模板的信息。下一个模板头告诉编译器关于赋值运算符函数模板的信息。注意,编译器将能够从赋值源(rhs
)的类型中推导出U
的模板参数。在将这个操作符添加到rational
类模板之后,您现在应该能够让您的测试程序工作了。
**添加一个成员模板构造器,其工作方式类似于赋值操作符。**换句话说,给rational
添加一个看起来像复制构造器但实际上不是的构造器。复制构造器只复制相同类型的对象,即rational<T>
。这个新的构造器用不同的基本类型复制 rational 对象,rational<U>
。将您的解决方案与清单 52-9 进行比较。
template<class T>
template<class U>
rational<T>::rational(rational<U> const& copy)
: numerator_{copy.numerator()}, denominator_{copy.denominator()}
{}
Listing 52-9.Defining a Member Constructor Template
请注意模板头是如何堆叠的。首先是类模板头,然后是构造器模板头。通过完成所有操作符来完成 rational
**类。**新的课程太大了,这里不包括,但是你可以从书的网站下载完整的模块。
模板变量
变量也可以是模板。想象一下你期望一个模板变量定义如何工作。<numbers>
头定义了常用数学常量的几个模板常量,比如 π 、 e (自然对数底)、√2 等等。标准使用的惯例是用后缀_v
定义模板名,然后去掉后缀,用双模板参数实例化模板,例如:
template<class T> constexpr T pi_v = 3.141592653589793238462643383279502884L;
constexpr double pi = pi_v<double>;
为 rational
定义一个 pi
**模板。**它实际上比浮点模板更棘手,因为浮点模板可以根据需要依靠编译器将long double
常量转换为double
或float
。对于rational
,如果你试图定义一个需要long long
模板参数的近似值,那么这个参数对于较小的参数类型就不起作用了。所以现在,保持简单,使用31416
和10000
,这适用于short
和更大的类型。将您的变量定义与清单 52-10 进行比较。
template<class T>
inline const rational<T> pi{ 31416, 10000 };
Listing 52-10.Defining a Variable Template
因为双参数构造器不是constexpr
,所以pi
变量不能是constexpr
。但是它可以是inline
,就像一个inline
函数。编译器试图立即使用变量值,而不是从内存中获取它。
使用模板和类型参数编程打开了编程能力和灵活性的新世界。模板允许你写一次函数或类,并允许编译器为不同的模板参数生成实际的函数和类。然而,有时一种尺寸不能适合所有人,你必须允许例外。下一篇文章将介绍如何通过编写模板特化来做到这一点。