五十三、模板特化
也许 C++ 最强大的特性是能够编写一个模板,然后多次使用该模板,每次使用不同的模板参数。为规则开辟例外的能力放大了这种力量。也就是说,您可以告诉编译器对大多数模板参数使用一个模板,只是对于某些参数类型,它应该使用不同的模板定义。这个探索引入了这个特性。
实例化和特化
模板术语很复杂。当你使用一个模板时,它被称为实例化模板。一个模板实例是编译器通过将模板参数应用于模板定义而创建的一个具体函数或类。模板实例的另一个名字是特化。因此,rational<int>
是模板rational<>
的特化。
因此,特化是一组特定模板参数的模板的实现。C++ 允许您为一组特定的模板参数定义一个定制的专用化;也就是说,您可以为模板设定的规则创建一个例外。当您定义特化时——而不是让编译器为您实例化模板——它被称为显式特化。因此,编译器自动创建的特化将是一个隐式特化。(显式特化也被称为完全特化,其原因将在下一次探索中变得清楚。)
例如,标准库的<type_traits>
模块支持许多描述、表征和查询类型能力的类模板。让我们从一个非常简单的模板is_void<>
开始,它简单地表明它的模板参数是否是void
类型。清单 53-1 显示了一个可能的实现。主模板从std::false_type
继承而来,void
类型的特化从std::true_type
派生而来。
template<class T>
class is_void : public std::false_type
{};
template<>
class is_void<void> : public std::true_type
{};
Listing 53-1.The is_void Class Template
当你编写自己的模板类时,你可以使用is_void<T>::value
来确定类型T
是否是void
类型。如您所见,显式特化以template<>
开始(注意空的尖括号)。接下来是定义。请注意,类名是完整的专用模板名:is_void<void>
。编译器就是这样知道你在专攻什么的。初始模板定义称为主模板,以区别于模板特化。
您的显式特化完全替换了该模板参数的模板声明;如果模板采用多个参数,您必须为每个参数提供一个特定的值)。一般来说,完全特化将实现相同的成员,只是方式不同,但这是惯例,而不是语言规则。有时,特化可能与主模板非常不同。
假设一个客户喜欢你的rational
类模板,但是想在他们自己的模板中使用它,有时他们的模板参数类型是void
,所以他们想让rational<void>
做一些有用的事情。你不能有一个类型为void
的数据成员,所以编译器会拒绝rational<void>
,除非你写一个显式的特化。什么有意义?
我能想到的就是表示数值 0/1。写一个明确的特殊化为 rational<void>
**。**清单 53-2 展示了一种编写它的方法。
import rational;
template<>
class rational<void>
{
public:
using value_type = void;
rational() {}
int numerator() const { return 0; }
int denominator() const { return 1; }
};
Listing 53-2.Specializing rational<void>
您不需要实现reduce()
或类似的东西,因为rational<void>
只有一个值,即零。numerator()
和denominator()
函数总是返回相同的值。这不是一个特别有用的类,但是它展示了一个特化可能与主模板非常不同。
自定义比较器
map
容器允许您提供一个定制的比较器。默认行为是map
使用模板类std::less<>
,它是一个使用<
操作符来比较键的仿函数。如果你想存储一个无法与<
相比的类型,可以专门为你的类型定制std::less
。例如,假设您有一个person
类,它存储一个人的姓名、地址和电话号码。你想在一个按名字排序的map
中存储一个person
。你需要做的就是编写一个模板特化std::less<person>
,如清单 53-3 所示。
import <functional>;
import <iostream>;
import <map>;
import <string>;
import <string_view>;
class person {
public:
person() : name_{}, address_{}, phone_{} {}
person(std::string_view name,
std::string_view address,
std::string_view phone)
: name_{name}, address_{address}, phone_{phone}
{}
std::string const& name() const { return name_; }
std::string const& address() const { return address_; }
std::string const& phone() const { return phone_; }
private:
std::string name_, address_, phone_;
};
namespace std {
template<>
struct less<person> {
bool operator()(person const& a, person const& b) const {
return a.name() < b.name();
}
};
}
int main()
{
std::map<person, int> people;
people[person{"Ray", "123 Erewhon", "555-5555"}] = 42;
people[person{"Arthur", "456 Utopia", "123-4567"}]= 10;
std::cout << people.begin()->first.name() << '\n';
}
Listing 53-3.Specializing std::less to Compare person Objects by Name
您可以特化在std
名称空间中定义的类模板(而不是函数模板),但是您不能向std
添加新的声明。在<functional>
模块中声明了std::less
模板。这个模块为所有的关系和等式操作符定义了比较器模板,除此之外还有很多。有关详细信息,请查阅语言参考资料。现在重要的是std::less
主模板是什么样子,也就是 C++ 在找不到显式特化(比如std::less<person
>)时使用的主模板。编写一个类模板less
的定义,它将作为一个主模板,用<
操作符来比较任何可比较的对象。将您的解决方案与清单 53-4 进行比较。
template<class T>
struct less
{
bool operator()(T const& a, T const& b) const { return a < b; }
};
Listing 53-4.The Primary std::less Class Template
如果你能找到源代码,看看你的标准库的<functional>
模块。这可能比列出 53-4 更复杂,但你应该能找到你能识别和理解的东西。
特化函数模板
您可以特化一个函数模板,但是您应该更喜欢重载而不是模板。比如继续用absval
(探索 50 )的模板形式。假设您有一个任意精度的整数类,integer
,并且它有一个有效的绝对值函数(也就是说,它只是清除了符号位,所以没有必要进行比较)。你想用有效的方法取integer
的绝对值,而不是absval
的模板形式。尽管 C++ 允许您特化absval<>
函数模板,但更好的解决方案是覆盖absval
函数(不是模板):
integer absval(integer i)
{
i.clear_sign_bit();
return i;
}
当编译器看到对absval
的调用时,它会检查参数的类型。如果类型与非模板函数中使用的参数类型匹配,编译器会安排调用该函数。如果它不能匹配实参类型和形参类型,它就检查模板函数。精确的规则很复杂,我将在本书的后面讨论它们。现在,只要记住编译器更喜欢非模板函数而不是模板函数,但是如果它不能在实参类型和非模板函数的形参类型之间找到良好的匹配,它将使用模板函数而不是非模板函数。
然而,有时候你不得不写一个模板函数,即使你只是想重载absval
函数。例如,假设您想改进rational<T>
类模板的绝对值函数。没有必要将整个值与零进行比较;只比较分子,避免不必要的乘法。
template<class T>
rational<T> absval(rational<T> const& r)
{
if (r.numerator() < 0) // to avoid unnecessary multiplications in operator<
return -r;
else
return r;
}
当你调用absval
时,以通常的方式给它传递一个参数。如果传递一个int
、double
或其他内置的数值类型,编译器会实例化原始的函数模板。如果你传递一个integer
对象,编译器调用重载的非模板函数,如果你传递一个rational
对象,编译器实例化重载的函数模板。
特征
在本文的前面,我向您介绍了<type_traits>
模块,它有很多检查类型的方法。您还看到了特征模板的另一个例子:std::numeric_limits
。<limits>
模块定义了一个名为std::numeric_limits
的类模板。主模板相当枯燥,说该类型的精度为零,基数为零,等等。这个模板有意义的唯一方式是将其特化。因此,<limits>
模块也为所有内置类型定义了模板的显式特化。因此,您可以通过调用std::numeric_limits<int>::min()
来发现最小的int
,或者用std::numeric_limits<double>::radix
来确定double
的浮点基数,以此类推。每个特化都声明相同的成员,但是具有该特化特有的值。(注意,编译器不会强制每个特化声明相同的成员。C++ 标准为numeric_limits
规定了这个要求,正确实现标准取决于库作者,但是编译器不提供任何帮助。)
您可以在创建数值类型时定义自己的特化,比如rational
。定义模板的模板涉及到一些困难,我将在下一次探索中介绍,所以现在,回到清单 49-10 和老式的非模板rational
类,它硬编码int
作为基本类型。清单 53-5 展示了如何为这个rational
类特化numeric_limits
。
namespace std {
template<>
class numeric_limits<rational>
{
public:
static constexpr bool is_specialized{true};
static constexpr rational min() noexcept {
return rational(numeric_limits<int>::min());
}
static constexpr rational max() noexcept {
return rational(numeric_limits<int>::max());
}
static rational lowest() noexcept { return -max(); }
static constexpr int digits{ 2 * numeric_limits<int>::digits };
static constexpr int digits10{ numeric_limits<int>::digits10 };
static constexpr int max_digits10{ numeric_limits<int>::max_digits10 };
static constexpr bool is_signed{ true };
static constexpr bool is_integer{ false };
static constexpr bool is_exact{ true };
static constexpr int radix{ 2 };
static constexpr bool is_bounded{ true };
static constexpr bool is_modulo{ false };
static constexpr bool traps{ std::numeric_limits<int>::traps };
static rational epsilon() noexcept
{ return rational{1, numeric_limits<int>::max()-1}; }
static rational round_error() noexcept
{ return rational{1, numeric_limits<int>::max()}; }
// The following are meaningful only for floating-point types.
static constexpr int min_exponent{ 0 };
static constexpr int min_exponent10{ 0 };
static constexpr int max_exponent{ 0 };
static constexpr int max_exponent10{ 0 };
static constexpr bool has_infinity{ false };
static constexpr bool has_quiet_NaN{ false };
static constexpr bool has_signaling_NaN{ false };
static constexpr float_denorm_style has_denorm {denorm_absent};
static constexpr bool has_denorm_loss {false};
// The following are meant only for floating-point types, but you have
// to define them, anyway, even for nonfloating-point types. The values
// they return do not have to be meaningful.
static constexpr rational infinity() noexcept { return max(); }
static constexpr rational quiet_NaN() noexcept { return rational{}; }
static constexpr rational signaling_NaN() noexcept { return rational{}; }
static constexpr rational denorm_min() noexcept { return rational{}; }
static constexpr bool is_iec559{ false };
static constexpr bool tinyness_before{ false };
static constexpr float_round_style round_style{ round_toward_zero };
};
} // namespace std
Listing 53-5.Specializing numeric_limits for the rational Class
这个例子有一些新的东西。它们现在并不重要,但是在 C++ 中,你必须把所有微小的细节都处理好,否则编译器会发出严厉的反对。从namespace std
开始的第一行是如何在标准库中特化模板。不允许向标准库添加新名称,但是允许特化标准库已经定义的模板。注意名称空间的左花括号,在清单的最后一行有相应的右花括号。(这个话题将在探索 56 中更深入的讨论。)
成员函数的名字和体之间都有noexcept
。这告诉编译器该函数不会抛出任何异常(回想一下 Exploration 48 )。
constexpr
说明符类似于const
,但是它告诉编译器该函数在编译时是可调用的。为了让一个函数成为constexpr
,编译器强加了一些限制。它调用的任何函数也必须是constexpr
。函数参数和返回类型必须是内置的或者可以用constexpr
构造器构造的类型。如果违反了任何限制,则不能声明该功能constexpr
。这样,gcd()
函数不能是constexpr
,所以reduce()
不能是constexpr
,所以双参数构造器不能是constexpr
。能够编写一个在编译时被调用的函数的价值是极其有用的,我们将在未来返回到constexpr
。
Tip
第一次写模板的时候,从非模板版本开始。调试非模板函数或类要容易得多。一旦你得到了非模板版本的工作,然后把它变成一个模板。
模板特化还有许多其他用途,但是在我们得意忘形之前,下一篇文章将研究一种特殊的特化,其中您的特化仍然需要模板参数,称为部分特化。
二十四、部分模板特化
显式特化要求您为每个模板参数指定一个模板参数,在模板头中不留下任何模板参数。但是,有时您希望只指定一些模板参数,在头中保留一个或多个模板参数。C++ 让您可以做到这一点,甚至更多,但只是针对类模板,正如本文所描述的。
退化对
标准库在<utility>
头中定义了std::pair<T, U>
类模板。这个类模板是一对对象的简单持有者。模板参数指定了这两个对象的类型。清单 54-1 描述了这个简单模板的通用定义。(为了便于管理,我省略了一些涉及更高级编程技术的成员。)
template<class T, class U>
struct pair
{
using first_type = T;
using second_type = U;
T first;
U second;
pair();
pair(T const& first, U const& second);
template<class T2, class U2>
pair(pair<T2, U2> const& other);
};
Listing 54-1.The pair Class Template
记住关键字struct
的意思和class
一样。不同的是,默认的访问级别是public
。很多简单的类都用struct
,但是我喜欢一直用class
,只是为了不变。但是标准用struct
描述std::pair
,所以我给清单 54-1 选了同样的。甚至当使用struct
关键字定义时,我仍然称类型为“类”,因为它是。
正如您所看到的,pair
类模板并没有做多少事情。std::map
类模板可以使用std::pair
来存储键和值。少数函数,如std::equal_range
,为了返回两条信息,返回一个pair
。换句话说,pair
是标准库的一个有用的部分,尽管有些枯燥。
如果 T
或者 U
是 void
会怎么样?
虽然void
已经到处出现,通常作为函数的返回类型,但我并没有过多讨论。void
类型表示“无类型”这对于从不返回值的函数返回很有用,但是你不能用void
类型声明对象,编译器也不允许你使用void
作为数据成员。因此,pair<int, void>
导致了一个错误。
随着你开始越来越多地使用模板,你会发现自己处于不可预知的情况。一个模板可能包含一个模板,这个模板可能包含另一个模板,突然你发现一个模板,比如pair
,正在用你以前从未想象过的模板实参进行实例化,比如void
。为了完整起见,让我们为允许一两个void
模板参数的pair
添加特化。只有当模板参数是用户定义的类型时,标准才允许库模板的特化。因此,为void
类型指定std::pair
会导致未定义的行为。所以我们会特化自己的pair
类模板,而不是标准库中的std::pair
模板。
写一个明确的特殊化为 pair<void, void>
。它不能存储任何东西,但是你可以声明类型为pair<void, void>
的对象。为了测试您的解决方案,编译器需要首先看到主模板,然后是特化,所以请记住在您的测试代码中包含这两者。将您的解决方案与清单 54-2 进行比较。
template<>
struct pair<void, void>
{
using first_type = void;
using second_type = void;
pair(pair const&) = default;
pair() = default;
pair& operator=(pair const&) = default;
};
Listing 54-2.Specializing pair<> for Two void Arguments
构造器无关紧要,模板特化不能定义任何数据成员,所以这种特化是基本的,依赖于编译器自己对构造器和赋值操作符的默认定义。更困难的是一个论点的情况。对于该对的另一部分,您仍然需要一个模板参数。这需要部分专业化。
部分专业化
当你编写一个模板特化,它涉及到一些,但不是全部的模板参数时,它被称为部分特化。一些程序员称显式特化为完全特化,以帮助区别于部分特化。部分特化是显式的,所以短语完全特化更具描述性,我将在本书的其余部分使用它。
从一个模板头开始部分特化,这个模板头列出了您没有特化的模板参数。然后定义专业化。与完全特化一样,通过列出所有模板参数来命名您正在特化的类。一些模板参数依赖于特化的参数,一些模板参数固定有特定的值。这就是为什么这种专业化是片面的。
与完全特化一样,特化的定义完全取代了一组特定模板参数的主模板。按照惯例,您保持相同的接口,但是实际的实现取决于您。
如果第一个模板参数是void
,清单 54-3 显示了pair
的部分特化。
template<class U>
struct pair<void, U>
{
typedef void first_type;
typedef U second_type;
U second;
pair() = default;
pair(pair const&) = default;
pair(U const& second) : second{second} {}
template<class U2>
pair(pair<void, U2> const& other);
};
Listing 54-3.Specializing pair for One void Argument
在清单 54-3 、的基础上,写一个 pair
的局部特殊化,带一个 void
**第二个自变量。**将您的解决方案与清单 54-4 进行比较。
template<class T>
struct pair<T, void>
{
typedef T first_type;
typedef void second_type;
T first;
pair() = default;
pair(pair const&) = default;
pair(T const& first) : first{first} {}
template<class T2>
pair(pair<T2, void> const& other);
};
Listing 54-4.Specializing pair for the Other void Argument
不管是否存在任何部分或完整的特化,您仍然以同样的方式使用pair
模板:总是使用两个类型参数。编译器检查这些模板参数,并确定使用哪个特化。
部分模板特化的模板参数不必是模板本身的参数。它们可以是任何被特化的模板的任何参数。例如,清单 53-5 显示了std::numeric_limits<rational>
的完全特化,假设 rational 被硬编码为使用一个int
类型。但是更有用的是rational<>
类模板。在这种情况下,您将需要numeric_limits
的部分特化,如清单 54-5 所示。
namespace std {
template<class T>
class numeric_limits<rational<T>>
{
public:
static constexpr bool is_specialized{true};
static constexpr rational<T> min() noexcept {
return rational<T>(numeric_limits<T>::min());
}
static constexpr rational<T> max() noexcept {
return rational<T>(numeric_limits<T>::max());
}
static rational<T> lowest() noexcept { return -max(); }
static constexpr int digits{ 2 * numeric_limits<T>::digits };
static constexpr int digits10{ numeric_limits<T>::digits10 };
static constexpr int max_digits10{ numeric_limits<T>::max_digits10 };
static constexpr bool is_signed{ numeric_limits<T>::is_signed };
static constexpr bool is_integer{ false };
static constexpr bool is_exact{ true };
static constexpr int radix{ 2 };
static constexpr bool is_bounded{ numeric_limits<T>::is_bounded };
static constexpr bool is_modulo{ false };
static constexpr bool traps{ std::numeric_limits<T>::traps };
... omitted for brevity
};
} // namespace std
Listing 54-5.Partially Specializing numeric_limits for rational
部分特化的函数模板
不能部分特化函数模板。如前所述,允许完全特化,但不允许部分特化。不好意思。使用重载来代替,这通常比模板特化要好。
值模板参数
在我展示下一个部分特化的例子之前,我想介绍一个新的模板特性。模板通常使用类型作为参数,但也可以使用值。使用类型和可选名称声明值模板参数,与声明函数参数的方式非常相似。值模板参数仅限于可以指定编译时常量的类型:bool
、char
、int
等等,但是不允许使用字符串和大多数类。
例如,假设您想要修改您为 Exploration 50 编写的fixed
类,以便开发人员可以指定小数点后的位数。同时,您还可以使用模板参数来指定底层类型,如清单 54-6 所示。
template<class T, int N>
class fixed
{
public:
using value_type = T;
static constexpr int places{N};
static constexpr int places10{ipower(10, N)};
fixed();
fixed(T const& integer, T const& fraction);
fixed& operator=(fixed const& rhs);
fixed& operator+=(fixed const& rhs);
fixed& operator*=(fixed const& rhs);
... and so on...
private:
T value_; // scaled to N decimal places
};
template<class T, int N>
fixed<T, N>::fixed(value_type const& integer, value_type const& fraction)
: value_(integer * places10 + fraction)
{}
template<class T1, int N1, class T2, int N2>
bool operator==(fixed<T1,N1> const& a, fixed<T2,N2> const& b);
... and so on...
Listing 54-6.Changing fixed from a Class to a Class Template
将fixed
类转换成类模板的关键挑战是根据places
定义places10
。C++ 没有求幂运算符,但是你可以写一个constexpr
函数来计算一个整数的幂。编译时ipower
函数见清单 54-7 。
/// Compute base to the exp-th power at compile time.
template<class Base, class Exp>
Base constexpr ipower(Base base, Exp exp)
{
if (exp < Exp{})
throw std::domain_error("No negative powers of 10");
if (exp == Exp{})
{
if (base == Base{})
throw std::domain_error("0 to 0th power is not allowed");
return Base{1};
}
Base power{base};
for (Exp e{1}; e != exp;)
{
// invariant(power == base ** e)
if (e + e < exp)
{
power *= power;
e += e;
}
else
{
power *= base;
++e;
}
}
return power;
}
Listing 54-7.Computing a Power of 10 at Compile Time
假设您有一个实例化fixed<long, 0>
的应用程序。这种退化的情况与普通的long
没有什么不同,但是管理隐式小数点的开销和复杂性增加了。进一步假设您的应用程序的性能测量揭示了这种开销对应用程序的整体性能有可测量的影响。因此,您决定对fixed<T, 0>
的情况使用部分特化。使用部分特化,以便模板仍然接受基础类型的模板参数。
您可能想知道为什么应用程序程序员不简单地用普通的long
替换fixed<long, 0>
。在某些情况下,这是正确的解决方案。然而,在其他时候,fixed<long, 0>
的使用可能会隐藏在另一个模板中。因此,问题变成了特化哪个模板。为了这次探索,我们专做fixed
。
请记住,任何特化都必须提供完整的实现。你不需要把免费的函数特殊化。通过特化fixed
类模板,我们得到了我们需要的性能提升。清单 54-8 显示了fixed
的部分特化。
template<class T>
class fixed<T, 0>
{
public:
using value_type = T;
static constexpr T places{0};
static constexpr T places10{1};
fixed() : value_{} {}
fixed(T const& integer, T const&);
fixed& operator=(fixed const& rhs) { value_ = rhs; }
fixed& operator+=(fixed const& rhs) { value_ += rhs; }
fixed& operator*=(fixed const& rhs) { value_ *= rhs; }
... and so on...
private:
T value_; // no need for scaling
};
template<class T>
fixed<T, 0>::fixed(value_type const& integer, value_type const&)
: value_(integer)
{}
Listing 54-8.Specializing fixed for N == 0
如果rational
或fixed
的模板参数不是整数怎么办?如果用户不小心使用了std::string
怎么办?当然,灾难会接踵而至,用户会受到错误消息的轰炸。隐藏在这些消息深处的是真正的原因,但是用户发现这个问题有多容易呢?C++ 20 为模板的作者提供了一种简单的方法来指定模板参数的要求,这是下一篇文章的主题。
五十五、模板约束
模板的一个缺点是它们很容易被误用,意外地使用错误的类型作为模板参数会使编译器感到困惑,以至于它发出的错误消息需要 C++ 的高级学位才能破译。不过不用担心,因为模板作者可以对模板参数指定约束。这个探索描述了如何在你的模板上写约束。
约束函数模板
考虑一下如果你要传递一个字符串给ipower()
函数(清单 54-7 )或者一个浮点值会发生什么。该函数只对整型参数有效,但是因为 C++ 有几种不同的整型类型,所以编写一个模板比多次编写同一个函数更有意义,每次编写一个整型类型。我们真正想要的是一种将模板参数限制为整型的方法。清单 55-1 展示了如何将参数限制为整型。
/// Compute base to the exp-th power at compile time.
template<class Base, class Exp>
Base constexpr ipower(Base base, Exp exp)
requires std::integral<Base> and std::integral<Exp>
{
if (exp < Exp{})
throw std::domain_error("No negative powers of 10");
if (exp == Exp{})
{
if (base == Base{})
throw std::domain_error("0 to 0th power is not allowed");
return Base{1};
}
Base power{base};
for (Exp e{1}; e != exp;)
{
// invariant(power == base ** e)
if (e + e < exp)
{
power *= power;
e += e;
}
else
{
power *= base;
++e;
}
}
return power;
}
Listing 55-1.Requiring Template Argument Types to Be Integral
现在,如果您试图传递一个字符串或浮点值,编译器会告诉您已经违反了integral
约束。尝试用不同的参数类型调用 ipower
(),看看你的编译器会发出什么样的消息。
requires
修饰符跟在函数模板声明或定义中的函数头之后。requires
后面的内容看起来像一个布尔表达式,但略有不同。约束可以与逻辑操作符(and
、or
、not
)结合,并且,像布尔表达式一样,编译器用短路来计算约束。如果and
的左侧约束为假,则约束失败,不评估右侧约束。如果or
的左侧约束为真,则约束通过,而不评估右侧约束。对于复杂的约束,可以使用括号。
您还可以使用约束来区分重载函数。例如,std::vector<>
模板有几个名为insert
的函数,用于将一个或多个值插入向量。一个insert
函数是一个成员函数模板,它以两个迭代器作为参数,将一系列值复制到向量的特定位置:
template<class InputIterator>
iterator insert(const_iterator pos, InputIterator first, InputIterator last);
还有一个函数可以插入单个值的多个副本:
iterator insert(const_iterator pos, size_type count, T const& value);
编译器如何解释下面的代码?
std::vector<int> v;
v.insert(v.end(), 10, 20);
因为10
和20
的类型是int
,所以在InputIterator
类型设置为int
的情况下调用模板函数。显然,10 和 20 不是迭代器,编译器最终会发出许多错误。因此函数的迭代器形式被约束如下:
template<class InputIterator>
iterator insert(const_iterator pos, InputIterator first, InputIterator last)
requires std::input_iterator<InputIterator>;
<iterator>
头定义了std::input_iterator
。
现在轮到你了。修改清单51-5中的 copy()
函数,对模板参数添加合适的约束。<iterator>
头提供了std::output_iterator<I, T>
,其中I
是要测试的迭代器,T
是值类型。<ranges>
头提供了std::ranges::input_range<R>
和std::ranges::range_value_t<R>
,后者产生了范围R
的值类型。将您的函数与清单 55-2 进行比较。
template<class Input, class Output>
Output copy(Input input, Output output)
requires
std::ranges::input_range<Input> and
std::output_iterator<Output, std::ranges::range_value_t<Input>>
{
for (auto const& item : input)
*output++ = item;
return output;
}
Listing 55-2.Constraining the copy Function’s Arguments
指定约束的另一种方式是指定需要对函数参数执行的操作。例如,假设您想要实现一个操作符来将一个rational<T>
值乘以任何数值标量值,并且您想要允许用户定义的类型(std::integral<T>
和std::floating_point<T>
只适用于内置类型)。清单 55-3 展示了如何根据乘法和除法运算来定义约束。
template<class T, class U>
U operator*(rational<T> const& lhs, U const& rhs)
requires
requires(T lhs, U rhs) {
(lhs * rhs) / lhs;
}
{
return lhs.numerator() * rhs / lhs.denominator();
}
Listing 55-3.Constraining a Multiplication Operator
第二个requires
关键字开始一个requires
表达式。这个requires
表达式后面是看起来像函数的参数。花括号中是一系列需求,每个需求都以分号结束。在清单 55-3 中,需求只是一个表达式。如果表达式有效,则要求为真。比方说,如果用户试图将一个字符串传递给*
操作符,编译器会报告违反了(lhs * rhs) / lhs
约束。
列表中可能出现的另一种需求是类型需求,它只是类型的名称,比如成员类型名称或模板特化。如果类型有效,则要求为真。例如,所有标准容器都有一个名为size_type
的成员类型。如果你想写一个size()
函数来检查一个size_type
成员和一个size()
成员函数,你可以如清单 55-4 所示来写。
template<class T>
auto size(T const& container)
requires
requires(T container) {
container.size();
typename T::size_type;
{ container.size() } -> std::same_as<typename T::size_type>;
}
{
return container.size();
}
Listing 55-4.Constraining the size Function
container.size()
约束检查表达式是否有效,这意味着size()
成员函数是有效的。如果它是有效的,也就是说,编译器知道如何调用size()
成员函数,编译器检查第二个需求,或typename T::size_type
,它检查模板参数是否有一个size_type
类型成员。如果第二个要求为真,编译器检查第三个要求。这将使用标准的std::same_as
概念检查container.size()
是否有效以及返回类型是否为T::size_type
。最后一个需求包含了前两个,但是清单 55-4 展示了所有三个需求,只是为了展示需求表达的三种风格。
另一种语法是模板约束紧跟在模板头之后。清单 55-5 显示了与清单 55-4 相同的约束,但是语法不同。
template<class T>
requires
requires(T container) {
container.size();
typename T::size_type;
{ container.size() } -> std::same_as<typename T::size_type>;
}
auto size(T const& container)
{
return container.size();
}
Listing 55-5.Constraining the size Function
约束类模板
您还可以对类模板应用约束。例如,rational
模板要求其模板参数是整数类型:
template<class T>
requires std::integral<T>
class rational;
约束与函数模板的约束相同。在类定义中,还可以将约束应用于作为模板的单个成员函数。
为了进一步简化模板头,不使用class
来引入参数名,您可以使用一个概念,例如:
template<std::integral T>
class rational;
标准概念
如您所见,C++ 标准库提供了许多有用的约束测试。这些测试被称为概念。许多基本概念在<concepts>
标题中定义,附加概念在<iterator>
和<ranges>
中定义。在<concepts>
标题中定义的概念如下:
std::equality_comparable<T>
如果可以比较类型T
的值是否相等,则产生真约束。用==
运算符。如果调用者不提供谓词,find()
算法要求元素为equality_comparable
。
std::floating_point<T>
如果T
是内置浮点类型(float
、double
或long double
)之一,则产生真约束。
std::integral<T>
如果T
是内置整数类型之一(char
、short
、int
、long
或long long
,则产生一个真约束。
predicate<T>
如果T
是谓词,即返回布尔结果的函数,则产生真约束。许多算法,比如copy_if()
,需要一个谓词参数。
std::strict_weak_order<T>
如果类型为T
的值可以用<
运算符进行比较,则产生一个真正的约束,并且结果是一个严格的弱排序。这是在map
中使用T
作为键类型的要求。术语严格意味着一个表达式x < x
总是假的,而弱排序本质上是这样说的能力。
迭代器概念
<iterator>
头为每个迭代器类别定义了一个概念,加上一些更细粒度的概念。
std::bidirection_iterator<I>
如果I
是双向、随机访问或连续的,则产生真约束。
std::contiguous_iterator<I>
如果I
是连续的,则产生真约束。
std::forward_iterator<I>
如果I
是正向、双向、随机访问或连续,则产生真约束。
std::indirectly_readable<I>
如果I
是任何读迭代器,则产生一个真约束,也就是说,可以间接或通过解引用操作符(*
)读取一个值。
std::indirectly_writable<I>
如果I
是任何写迭代器,则产生一个真约束,也就是说,可以间接或通过解引用操作符(*
)写一个值。
std::input_iterator<I>
如果I
是输入、正向、双向、随机访问或连续,则产生真约束。
std::input_or_output_iterator<I>
如果I
是输入或输出迭代器,则产生真约束。这两种迭代器类型有一个共同的特点,那就是它们是可递增的,并且代码必须在迭代之间解引用迭代器一次。
std::output_iterator<I>
如果I
是输出、正向、双向、随机访问或连续,则产生真约束。
std::permutable<I>
如果I
可用于对可迭代范围内的数据进行重新排序,则产生真约束。置换算法可以移动或交换数据。
std::random_access_iterator<I>
如果I
是随机存取或连续的,则产生真约束。
std::sortable<I>
如果I
可用于对可迭代范围内的数据进行排序,则产生真约束。排序算法可以移动或交换数据,并且必须能够以严格的弱排序比较元素。
范围概念
<range>
头为每个迭代器类别定义了一个概念,加上一些更细粒度的概念。
std::ranges::bidirectional_range<R>
如果R
是双向、随机访问或连续范围,如链表、数组或向量,则产生真约束。
std::ranges::contiguous_range<R>
如果R
是一个连续的范围,如数组或向量,则产生一个真约束。
std::ranges::forward_range<R>
如果R
是正向、双向、随机访问或连续范围,如输入视图或标准容器,则产生真约束。
std::ranges::input_range<R>
如果R
是输入、前向、双向、随机访问或连续范围,如输入视图,则产生真约束。
std::ranges::output_range<R>
如果R
是输出、正向、双向、随机访问或连续范围,则产生真约束。到目前为止,在本书中,我们使用了输出迭代器,而不是输出范围。输出范围的一个例子是已经预先调整大小以适应预期输出的向量。
std::ranges::random_access_range<R>
如果R
是随机访问或连续范围,如数组或向量,则产生真约束。
std::ranges::range<R>
如果R
是任何范围,比如一对迭代器、一个视图或一个标准容器,则产生一个真约束。
std::ranges::sized_range<R>
如果R
是一个大小已知的范围,并且该大小可以在常量时间内确定(不是通过迭代该范围),则产生一个真约束。
std::ranges::view<R>
如果R
是一个视图,则产生一个真正的约束。作为一个视图,一个范围必须是轻量级的,也就是说,在固定的时间内是可移动和可销毁的。在恒定时间内可销毁意味着视图不能拥有该范围内的任何元素,因为销毁该范围需要销毁该范围内的对象。
写出你自己的概念
约束可以是与模板参数相关的布尔表达式。最常见的情况是,这个表达式使用一种特殊的模板来编写模板约束,称为概念。例如,假设您想要一个针对任何整数类型的约束,包括用户定义的类型。要求是,如果用户定义了一个整数类型,std::numeric_limits
模板必须专用于该类型:
template<class T>
concept any_integral = std::numeric_limits<T>::is_integer;
让我们看看在编写面向范围的类时概念的应用。join
视图获取一系列范围并将它们展平成一个范围。这种方法的一个实际应用是将一系列字符串连接成一个字符串。但是它并没有完全完成工作。最终结果是一个范围内的视图,可以用来构造一个新的字符串,但这通常需要将连接的视图保存到一个变量中,然后使用变量的begin()
和end()
来构造一个字符串。清单 55-6 显示了一个类,它可以在视图管道的末端为我们创建std::string
对象。
清单 55-6。 定义 store
函数模板
import <algorithm>;
import <concepts>;
import <iostream>;
import <ranges>;
import <string>;
import <vector>;
template<class Range>
concept can_reserve =
std::ranges::sized_range<Range> and
requires(Range r) {
r.reserve(0);
};
template<class Container>
concept can_insert_back =
requires(Container c) {
std::back_inserter(c);
};
template<can_insert_back Container>
class store_t
{
public:
using container_type = Container;
using value_type = std::ranges::range_value_t<container_type>;
store_t(container_type& output) : output_{output} {}
template<can_reserve Range>
Container& operator()(Range const& input) const {
output_.reserve(std::ranges::size(output_)+std::ranges::size(input));
std::ranges::copy(input, std::back_inserter(output_));
return output_;
}
template<class Range>
requires (not can_reserve<Range>)
Container& operator()(Range const& input) const {
std::ranges::copy(input, std::back_inserter(output_));
return output_;
}
private:
container_type& output_;
};
template<class T>
store_t<T> store(T& container) { return store_t<T>(container); }
template<class In, class Out>
Out& operator|(In range, store_t<Out> const& storer)
{
return storer(std::forward<In>(range));
}
int main() {
std::vector<std::string> strings{ "this" " is ", "a", " test", ".\n" };
std::string str;
std::ranges::views::join(strings) | store(str);
std::cout << str;
}
尽管can_insert_back
概念只有一种用途,定义一个单独的概念而不是使用一个本地模板约束有两个好处:
-
通过给约束命名,它为人类读者和维护者提供了一些文档。
-
一个单独的约束意味着类声明不那么杂乱,这使得它稍微容易阅读。
这些优点都是给人类读者的。编译器不在乎。can_reserve
概念类似。它减少了函数调用操作符周围的混乱,因此更容易看出一个操作符适用于可以为副本预分配内存的情况(比如,为一个向量),另一个适用于输出范围将一次扩展一个元素的情况(比如,为一个链表)。
模板约束和概念是对 C++ 20 的一个很好的补充,你应该期待该语言的未来版本通过标准库的其余部分扩展约束的使用。第三方库也将开始采用约束,这将使它们更容易使用。
下一篇文章介绍了一个语言特性,它可以帮助您管理自定义类型:名称空间。
五十六、名称和命名空间
几乎标准库中的每个名称都以std::
开头,只有标准库中的名称才允许以std::
开头。对于您自己的名字,您可以定义其他前缀,这是一个好主意,也是避免名字冲突的极好方法。库和大型程序尤其受益于正确的分区和命名。然而,模板和名称有些复杂,这种探索有助于澄清问题。
命名空间
名称std
是一个名称空间的例子,这是一个命名作用域的 C++ 术语。名称空间是一种组织名称的方式。当您看到以std::
开头的名称时,您知道它在标准库中。好的第三方库使用名称空间。例如,开源 Boost 项目( www.boost.org
)使用boost
名称空间来确保名称(如boost::container::vector
)不会干扰标准库中的类似名称,如std::vector
。应用程序也可以利用名称空间。例如,不同的项目团队可以将他们自己的名字放在不同的名称空间中,因此一个团队的成员可以自由地命名函数和类,而不需要与其他团队进行核对。例如,GUI 团队可能使用名称空间gui
并定义一个gui::tree
类,它管理用户界面中的一个树小部件。数据库团队可能会使用db
名称空间。因此,db::tree
可能表示用于在磁盘上存储数据库索引的树形数据结构。数据库调试工具可以使用两个tree
类,因为db::tree
和gui::tree
之间没有冲突。名称空间将名称分开。
要创建命名空间并在其中声明名称,必须定义命名空间。名称空间定义以关键字namespace
开始,后面跟着一个可选的标识符来命名名称空间。接下来是花括号内的声明。与类定义不同,命名空间定义不以右大括号后的分号结束。花括号中的所有声明都在名称空间的范围内。您必须在任何函数之外定义一个命名空间。清单 56-1 定义了名称空间numeric
,并在其中定义了rational
类模板。
namespace numeric
{
template<class T>
class rational
{
... you know what goes here...
};
template<class T>
bool operator==(rational<T> const& a, rational<T> const& b);
template<class T>
rational<T> operator+(rational<T> const& a, rational<T> const& b);
... and so on...
} // namespace numeric
Listing 56-1.Defining the rational Class Template in the numeric Namespace
命名空间定义可以是不连续的。这意味着您可以拥有许多独立的命名空间块,它们都属于同一个命名空间。因此,多个模块可以各自定义同一个名称空间,并且每个定义都向同一个公共名称空间添加名称。在模块接口中,您可以导出整个名称空间或仅导出名称空间中的某些名称。清单 56-2 展示了如何在同一个numeric
名称空间中定义fixed
类模板,即使是在不同的模块中(比如说,fixed
)。
export module fixed;
namespace numeric
{
export template<class T, int N>
class fixed
{
... copied from Exploration 54...
};
export template<class T, int N>
bool operator==(fixed<T,N> const& a, fixed<T,N> const& b);
export template<class T, int N>
fixed<T,N> operator+(fixed<T,N> const& a, fixed<T,N> const& b);
// and so on...
} // namespace numeric
Listing 56-2.Defining the fixed Class Template in the numeric Namespace
即使没有显式导出numeric
名称空间,导入fixed
模块的模块也会导入numeric::fixed
。因为名称空间没有被导出,所以如果您希望能够从其他模块调用它,每个自由函数都需要一个export
声明。与fixed
相关的自由函数和运算符必须在numeric
名称空间中定义。我将在后面的探索中解释为什么,但我现在想指出来,因为它非常重要。
当您在名称空间中声明但未定义实体(如函数)时,您可以选择如何定义该实体,如下所述:
-
使用相同或另一个命名空间定义,并在命名空间定义中定义实体。
-
在名称空间之外定义实体,并在实体名称前加上名称空间名称和范围运算符(
::
)。
清单 56-3 展示了两种定义风格。(声明在清单 56-1 和 56-2 中。)
namespace numeric
{
template<class T>
rational<T> operator+(rational<T> const& a, rational<T> const& b)
{
rational<T> result{a};
result += b;
return result;
}
}
template<class T, int N>
numeric::fixed<T, N> numeric::operator+(fixed<T, N> const& a, fixed<T, N> const& b)
{
fixed<T, N> result{a};
result += b;
return result;
}
Listing 56-3.Defining Entities in a Namespace
第一种形式很简单。一如既往,定义必须遵循声明。这是你最常看到的形式。
当定义很少时,可以使用第二种形式。编译器看到名称空间名称(numeric
),后面是作用域操作符,并且知道在该名称空间中查找后续名称(operator*
)。编译器认为函数的其余部分在命名空间范围内,因此您不必在声明的其余部分(即函数参数和函数体)指定命名空间名称。函数的返回类型在函数名之前,这使它位于名称空间范围之外,所以您仍然必须使用名称空间名称。为了避免歧义,不允许在一个名称空间中有一个名称空间和一个同名的类。
我在 Exploration 24 中提到的另一种编写函数返回类型的方式让你不用重复命名空间范围就可以编写返回类型,因为函数名为你建立了范围。不要以返回类型开始函数头,而是使用auto
关键字,并将返回类型放在函数参数之后,用->
表示返回类型,如清单 56-4 所示。
template<class T, int N>
auto numeric::operator+(fixed<T, N> const& a, fixed<T, N> const& b) -> fixed<T, N>
{
fixed<T, N> result{a};
result += b;
return result;
}
Listing 56-4.Alternative Style of Function Declaration
传统上,当您在模块中定义名称空间时,该模块包含一个名称空间定义,其中包含所有必需的声明和定义。当您在一个单独的源文件中实现函数和其他实体时,我发现编写一个显式的名称空间并在名称空间中定义函数是最方便的,但是有些程序员更喜欢省略名称空间定义。相反,它们在定义实体时使用名称空间名称和范围操作符。以名称空间名称和作用域操作符开头的实体名称就是一个由限定的名称的例子——也就是说,一个名称明确地告诉编译器在哪里可以找到该名称的声明。
名字rational<int>::value_type
是合格的,因为编译器知道在类模板rational
中查找value_type
,专门针对int
。名字std::vector
是一个限定名,因为编译器在名字空间std
中查找vector
。另一方面,编译器在哪里查找名字std
?在回答这个问题之前,我必须深入研究嵌套名称空间这个主题。
嵌套命名空间
命名空间可以嵌套,也就是说,您可以在另一个命名空间中定义一个命名空间,如下所示:
namespace exploring_cpp
{
namespace numeric {
template<class T> class rational
{
... and so on ...
};
}
}
为了使用嵌套的名称空间,限定符从最外层的名称空间开始按顺序列出所有的名称空间。用范围运算符(::
)分隔每个名称空间,例如:
exploring_cpp::numeric::rational<int> half{1, 2};
std::ranges::copy(source, destination);
顶级名称空间,如std
或exploring_cpp
,实际上是一个嵌套的名称空间。它的外部名称空间被称为全局名称空间。在任何函数之外声明的所有实体都在一个命名空间中——显式命名空间或全局命名空间。因此,函数之外的名字被称为在命名空间范围。短语全局范围是指在隐式全局名称空间中声明的名称,这意味着在任何显式名称空间之外。通过在名称前加上范围运算符来限定全局名称。
::exploring_cpp::numeric::rational<int> half{1, 2};
::std::ranges::copy(source, destination);
您阅读的大多数程序都不会使用显式的全局作用域运算符。相反,程序员倾向于依赖普通的 C++ 规则来查找名字,让编译器自己找到全局名字。到目前为止,你写的每个函数都是全局的;对这些函数的每次调用都是不合格的。编译器从来没有遇到过非限定名的问题。如果您遇到局部名称隐藏全局名称的情况,您可以显式引用全局名称。清单 56-5 展示了糟糕的名字选择可能带来的麻烦,以及如何使用合格的名字来摆脱困境。
1 import <cmath>;
2 import <numeric>;
3 import <vector>;
4
5 namespace stats {
6 // Really bad name for a functor to compute sum of squares,
7 // for use in determining standard deviation.
8 class std
9 {
10 public:
11 std(double mean) : mean_{mean} {}
12 double operator()(double acc, double x)
13 const
14 {
15 return acc + square(x - mean_);
16 }
17 double square(double x) const { return x * x; }
18 private:
19 double mean_;
20 };
21
22 // Really bad name for a function in the stats namespace.
23 // It computes standard deviation.
24 double stats(::std::vector<double> const& data)
25 {
26 double std{0.0}; // Really, really bad name for a local variable
27 if (not data.empty())
28 {
29 double sum{::std::accumulate(data.begin(), data.end(), 0.0)};
30 double mean{sum / data.size()};
31 double sumsq{::std::accumulate(data.begin(), data.end(), 0.0,
32 stats::std(mean))};
33 double variance{sumsq / data.size() - mean * mean};
34 std = ::std::sqrt(variance);
35 }
36 return std;
37 }
38 }
Listing 56-5.Coping with Conflicting Names
局部变量std
与同名的名称空间并不冲突,因为编译器知道只有类名和名称空间才能出现在作用域操作符的左侧。另一方面,类std
确实冲突,所以使用一个空的std::
限定符是不明确的。您必须使用::std
(对于标准库名称空间)或stats::std
(对于类)。对局部变量的引用必须使用普通的std
。
第 24 行的名称stats
命名了一个函数,所以它不会与名称空间stats
冲突。因此,在第 32 行使用stats::std
不会有歧义。
第 29 行和第 31 行调用的accumulate
算法,正如其名称所暗示的那样。它将一个范围内的所有元素添加到一个起始值,要么通过调用+
操作符,要么通过调用一个二元函子,该函子将该范围内的和值作为参数。
删除全局 范围运算符从 ::std::accumulate
(第 29 行和第 31 行)到 std::accumulate
**。**重新编译程序。你的编译器给你什么消息?
将文件恢复到原始形式。从 ::std::vector
中删除第一个 ::
限定词(第 24 行)。编译器给你什么信息?
将文件恢复到原始形式。从 stats::std
中去掉 stats::
限定词(第 32 行)。编译器给你什么信息?
理智的人不会故意在 C++ 程序中命名一个类std
,但我们都会犯错误。(也许你有一个在建筑 CAD 系统中表示建筑元素的类,你不小心省略了stud
中的字母u
。)通过查看编译器在遇到名称冲突时发出的各种消息,当您意外创建的名称与第三方库或项目中另一个团队发明的名称冲突时,您可以更好地识别这些错误。
大多数应用程序程序员不必使用全局范围前缀,因为您可以小心选择不冲突的名称。另一方面,库的作者永远不知道他们的代码将在哪里被使用,或者代码将使用什么名字。因此,谨慎的库作者经常使用全局范围前缀。
全局名称空间
在所有名称空间之外声明的名字是全局的。过去,我使用全局来表示“在任何函数之外”,但那是在你了解名称空间之前。C++ 程序员引用在命名空间范围声明的名字,这是我们所说的“在任何函数之外”这种名称可以在命名空间中声明,也可以在任何显式命名空间之外声明。
程序的main
函数必须是全局的,也就是说,在名称空间范围内,但不在任何名称空间内。如果你在一个名称空间中定义了另一个名为main
的函数,它不会干扰全局main
,但是它会让任何阅读你的程序的人感到困惑。
标准命名空间
如您所知,标准库使用std
名称空间。不允许在std
名称空间中定义任何名称,但是可以特化在std
中定义的模板,只要至少有一个模板参数是用户定义的类型。
C++ 标准库从 C 标准库继承了一些函数、类型和对象。您可以认出 C 派生的头文件,因为它们的名字以一个额外的字母c
开头;例如,<cmath>
是 C 头文件<math.h>
的 C++ 等价物。有些 C 名字,比如EOF
,不遵循命名空间规则。这些名字通常都是用大写字母写的,以警告你它们是特别的。你不必关心细节;请注意,不能对这些名称使用范围操作符,这些名称总是全局的。当你在语言参考中查找一个名字时,这些特殊的名字被称为宏。
C++ 标准在库实现如何继承 C 标准库方面提供了一定的灵活性。具体规则是一个形式为<header.h>
(对于一些 C header
,比如math
)的 C 头在全局命名空间中声明它的名字,实现决定这些名字是否也在std
命名空间中。形式为<cheader>
的 C 头文件在std
名称空间中声明了它的名字,实现也可以在全局名称空间中声明它们。无论您选择哪种风格,所有 C 标准函数都保留给实现,这意味着您不能在全局名称空间中自由使用任何 C 标准函数名。如果要使用相同的名称,必须在不同的名称空间中声明。有人喜欢<cstddef>
和std::size_t
,有人更喜欢<stddef.h>
和size_t
。选择一种风格并坚持下去。
我的建议是不要纠结于哪些名字来源于 C 标准库,哪些是 C++ 特有的。相反,标准库中的任何名字都是禁止使用的。唯一的例外是,当您为了相同的目的想要使用相同的名称,但是在您自己的名称空间中。例如,你可能想重载abs
函数来处理rational
或fixed
对象。在它们各自的名称空间中这样做,与所有重载操作符和其他自由函数放在一起。
Caution
许多 C++ 参考省略了标准库的 C 部分。但是,正如您所看到的,C 部分在名称冲突方面是最有问题的。因此,确保你的 C++ 参考是完整的,或者用完整的 C 18 库参考来补充不完整的 C++ 参考。
使用名称空间
为了使用任何名字,C++ 编译器必须能够找到它,这意味着要识别它被声明的范围。使用名称空间中的名称(如rational
或fixed
)的最直接的方法是使用限定名,即名称空间名称作为前缀,例如numeric
,后面跟着作用域操作符(::
)。
numeric::rational<long> pi_r{80143857L, 25510582L};
numeric::fixed<long, 6> pi_f{3, 141593};
当编译器看到名称空间名称和双冒号(::
)时,它知道在该名称空间中查找后续名称。不同名称空间中的相同实体名称不会发生冲突。
然而,有时候,您最终会大量使用名称空间,简洁成为一种美德。接下来的两节描述了几个选项。
命名空间别名
如果您有深度嵌套的名称空间或长名称空间名称,您可以使用自己的缩写或别名,例如
namespace rng = std::ranges;
rng::copy(source, destination);
请务必选择一个不会与其他名称冲突的别名。这种技术最好在一定范围内使用,以保持其效果尽可能有限,并避免意外。
using 指令
你以前见过一个using
指令,但是如果你需要复习,看看这个:
using namespace std;
语法如下:using
关键字、namespace
关键字和一个名称空间名称。一个using
指令指示编译器将命名空间中的所有名字视为全局名字。(精确的规则稍微复杂一些。但是,除非您有名称空间的嵌套层次结构,否则这种简化是准确的。)您可以列出多个using
指令,但是您冒着在名称空间中引入名称冲突的风险。一个using
指令只影响你放置它的范围。因为它会对名称查找产生很大的影响,所以尽可能将using
指令限制在最窄的范围内;通常这是一个内部块。
虽然using
指令有它的优点——我在本书中使用了它们——但是你必须小心。它们阻碍了名称空间的关键优势:避免名称冲突。不同名称空间中的名称通常不会冲突,但是如果您试图混合声明一个公共名称的名称空间,编译器将会报错。
如果您不小心使用了using
指令,您可能会意外地使用来自错误名称空间的名称。如果你幸运的话,编译器会告诉你你的错误,因为你的代码以违反语言规则的方式使用了错误的名称。如果你不够幸运,错误的名字会碰巧有相同的语法,直到很久很久以后你才会注意到你的错误。
不要在模块接口中放置using
指令。这破坏了每个导入您的模块的人的名称空间。尽可能将using
指令保持在本地,在尽可能小的范围内。
一般来说,我尽量避免使用using
指令。您应该习惯于阅读完全限定的名称。另一方面,有时长名字会妨碍对复杂代码的理解。我很少在同一个范围内使用一个以上的using
指令。到目前为止,我唯一一次这样做是当所有的名称空间都由同一个库定义时,所以我知道它们一起工作,我不会遇到命名问题。清单 56-6 展示了using
指令是如何工作的。
1 import <iostream>;
2
3 void print(int i)
4 {
5 std::cout << "int: " << i << '\n';
6 }
7
8 namespace labeled
9 {
10 void print(double d)
11 {
12 std::cout << "double: " << d << '\n';
13 }
14 }
15
16 namespace simple
17 {
18 void print(int i)
19 {
20 std::cout << i << '\n';
21 }
22 void print(double d)
23 {
24 std::cout << d << '\n';
25 }
26 }
27
28 void test_simple()
29 {
30 using namespace simple;
31 print(42); // ???
32 print(3.14159); // finds simple::print(double)
33 }
34
35 void test_labeled()
36 {
37 using namespace labeled;
38 print(42); // find ::print(int)
39 print(3.14159); // finds labeled::print(double)
40 }
41
42 int main()
43 {
44 test_simple();
45 test_labeled();
46 }
Listing 56-6.Examples of using Directives
如果尝试编译清单 56-6 会发生什么?
错误在第 31 行。using
指令有效地合并了simple
名称空间和全局名称空间。因此,现在你有两个名为print
的函数,它们接受一个int
参数,而编译器不知道你想要哪个。通过限定对print(42)
(第 32 行)的调用来解决这个问题,这样它就调用了simple
名称空间中的函数。你期望程序输出是什么?
试试看。确保你得到你想要的。第 31 行现在应该是这样的:
simple::print(42);
using 声明
比using
指令更具体,也更不危险的是using
声明。一个using
声明将一个名字从另一个名称空间导入到一个局部范围,如下所示:
using numeric::rational;
一个using
声明将名字添加到局部作用域,就像你已经显式声明了它一样。因此,在放置using
声明的范围内,您可以无条件地使用声明的名称(例如,rational
)。清单 56-7 展示了using
声明如何帮助避免您在清单 56-6 中使用指令时遇到的问题。
1 import <iostream>;
2
3 void print(int i)
4 {
5 std::cout << "int: " << i << '\n';
6 }
7
8 namespace labeled
9 {
10 void print(double d)
11 {
12 std::cout << "double: " << d << '\n';
13 }
14 }
15
16 namespace simple
17 {
18 void print(int i)
19 {
20 std::cout << i << '\n';
21 }
22 void print(double d)
23 {
24 std::cout << d << '\n';
25 }
26 }
27
28 void test_simple()
29 {
30 using simple::print;
31 print(42);
32 print(3.14159);
33 }
34
35 void test_labeled()
36 {
37 using labeled::print;
38 print(42);
39 print(3.14159);
40 }
41
42 int main()
43 {
44 test_simple();
45 test_labeled();
46 }
Listing 56-7Examples of using Declarations with Namespaces
预测程序的输出。
这一次,编译器可以找到simple::print(int)
,因为using
声明将名称注入局部范围。因此,本地名称不会与全局print(int)
函数冲突。另一方面,编译器不会为第 38 行调用::print(int)
。相反,它调用labeled::print(double)
,将42
转换为42.0
。
你对编译器的行为感到困惑吗?我来解释一下。当编译器试图解析重载的函数或运算符名称时,它会查找第一个声明匹配名称的范围。然后,它从该范围收集所有重载的名称,并且只从该范围收集。最后,它通过选择最佳匹配来解析名称(或者,如果找不到准确的匹配,则报告一个错误)。一旦编译器找到匹配,它就停止在其他范围或外部命名空间中查找。
在这种情况下,编译器会看到对print(42)
的调用,并首先在局部范围内查找名称print
,在这里它会找到一个从labeled
名称空间导入的名为print
的函数。所以它停止寻找名称空间,并试图解析名称print
。它找到一个函数,该函数带有一个double
参数。编译器知道如何将一个int
转换成一个double
,所以它认为这个函数匹配并调用它。编译器甚至从不查看全局名称空间。
你如何指示编译器也考虑全局 print
函数?
为全局print
函数添加一个using
声明。在第 37 行和第 38 行之间,插入以下内容:
using ::print;
当编译器试图解析print(int)
时,它会找到labeled::print(double)
和::print(int)
,两者都被导入到局部范围内。然后,它通过考虑这两个函数来解决重载。print(int)
函数是int
参数的最佳匹配。
现在在同一位置添加using simple::print;
。现在编译这个例子,你预计会发生什么?
现在编译器有太多的选择——它们会发生冲突。一个using
指令不会引起这种冲突,因为它只是改变了编译器查找名字的命名空间。然而,using
声明向局部范围添加了一个声明。如果你添加了太多的声明,这些声明会冲突,编译器会抱怨。
当一个using
声明命名一个模板时,该模板名被带入局部范围。编译器跟踪模板的全部和部分特化。using
声明只影响编译器是否找到模板。一旦找到模板并决定实例化它,编译器就会找到合适的特化。这就是为什么您可以特化一个在标准库中定义的模板——也就是说,在std
名称空间中。
一个using
指令和一个using
声明的关键区别在于一个using
指令不影响局部范围。然而,using
声明将非限定名引入了局部范围。这意味着你不能在同一个范围内声明你自己的名字。清单 56-8 说明了区别。
import <iostream>;
void demonstrate_using_directive()
{
using namespace std;
typedef int ostream;
ostream x{0};
std::cout << x << '\n';
}
void demonstrate_using_declaration()
{
using std::ostream;
typedef int ostream;
ostream x{0};
std::cout << x << '\n';
}
Listing 56-8.Comparing a using Directive with a using Declaration
ostream
的本地声明会干扰using
声明,但不会干扰using
指令。一个局部作用域只能有一个具有特定名称的对象或类型,一个using
声明会将该名称添加到局部作用域中,而一个using
指令则不会。
类中的using
声明
一个using
声明也可以导入一个类的成员。这不同于名称空间using
声明,因为您不能将任何旧成员导入任何旧类,但是您可以将名称从基类导入派生类。有几个原因可以解释你为什么想这么做。两个直接原因是
-
基类声明了一个函数,派生类声明了一个同名的函数,而您希望重载找到这两个函数。编译器只在单个类范围内查找重载。使用
using
声明将基类函数导入派生类范围,重载可以在派生类范围中找到两个函数,从而选择最佳匹配。 -
当私有继承时,可以通过在派生类的公共部分放置一个
using
声明来选择性地公开成员。
清单 56-9 展示了using
声明。随着您学习更高级的 C++ 技术,您将了解到using
声明的更多优点。
import <iostream>;
class base
{
public:
void print(int i) { std::cout << "base: " << i << '\n'; }
};
class derived1 : public base
{
public:
void print(double d) { std::cout << "derived: " << d << '\n'; }
};
class derived2 : public base
{
public:
using base::print;
void print(double d) { std::cout << "derived: " << d << '\n'; }
};
int main()
{
derived1 d1{};
derived2 d2{};
d1.print(42);
d2.print(42);
}
Listing 56-9.Examples of using Declarations with Classes
预测程序的输出。
类derived1
有一个名为print
的成员函数。调用d1.print(42)
将42
转换为42.0
并调用那个函数。类derived2
从基类导入print
。因此,重载决定了d2.print(42)
的最佳匹配,并在基类中调用print
。输出如下所示:
derived: 42
base: 42
名称查找
在没有名称空间的情况下,查找函数或操作符名称很简单。编译器首先在本地块中查找,然后在外部块和外部命名空间之前的内部命名空间中查找,直到最后,编译器搜索全局声明。它在包含匹配声明的第一个块中停止搜索。如果编译器正在寻找一个函数或操作符,那么这个名字可能会被重载,所以编译器会考虑在同一个作用域中声明的所有匹配的名字,而不考虑参数。
查找成员函数略有不同。如前所述,当编译器在类上下文中查找非限定名时,它首先在本地块和封闭块中进行搜索。对于所有的祖先类,搜索继续考虑类的成员,然后是它的基类,等等。同样,当查找重载名称时,编译器会考虑它在同一范围内找到的所有匹配名称,即同一类或块。
名称空间使名称查找规则变得复杂。假设您想要使用在exploring_cpp::numeric
名称空间中定义的rational
类型。您知道如何为类型使用限定名,但是如何处理加法或 I/O 操作符,例如下面的操作符:
exploring_cpp::numeric::rational<int> r;
std::cout << r + 1 << '\n';
加法运算符的全称是exploring_cpp::numeric::operator+
。但是通常,您使用加法运算符,而不指定名称空间。因此,编译器需要一些帮助来确定哪个命名空间包含操作符声明。诀窍是编译器检查操作数的类型,并在包含这些类型的名称空间中寻找重载运算符。这就是所谓的参数相关查找(ADL)。
编译器收集几组要搜索的范围。它首先使用普通的查找规则确定要搜索的范围,如本节开头所述。对于每个函数参数或运算符操作数,编译器还基于参数类型收集一组命名空间。如果类型是类类型,编译器选择包含类声明的命名空间和包含其所有祖先类的命名空间。如果类型是类模板的专用化,编译器将选择包含主模板的命名空间和所有模板参数的命名空间。编译器形成所有这些作用域的并集,然后在它们中搜索函数或运算符。如你所见,ADL 的目标是包容性的。编译器努力发现哪个作用域声明了操作符或函数名。
为了更好地理解 ADL 的重要性,请看一下清单 56-10 。
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string& s) : text_{s} {}
token& operator=(std::string const& s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
}
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
int main()
{
using namespace parser;
using namespace std;
vector<token> tokens{};
ranges::copy(ranges::istream_view<token>(std::cin), back_inserter(tokens));
ranges::copy(tokens, ostream_iterator<token>(cout, "\n"));
}
Listing 56-10.Reading and Writing Tokens
当你编译程序时会发生什么?
一些编译器试图提供帮助,用消息填充你的控制台。问题的核心是istream_iterator
和ostream_iterator
调用标准的输入(>>
)和输出(<<
)操作符。在清单 52-10 的情况下,编译器通过普通的查找将操作符定位为istream
和ostream
类的成员函数。标准库为内置类型声明了这些成员函数操作符,因此编译器无法找到类型为parser::token
的参数的匹配项。因为编译器在一个类范围内找到了匹配,所以它从来没有搜索过全局范围,所以它从来没有找到定制的 I/O 操作符。
编译器应用 ADL 并搜索parser
名称空间,因为<<
和>>
的第二个操作数的类型为parser::token
。它搜索std
名称空间,因为第一个操作数具有类型std::istream
或std::ostream
。它在这些命名空间中找不到 I/O 操作符的匹配项,因为这些操作符在全局范围内。
现在您明白了为什么在与主类型相同的名称空间中声明所有关联的操作符是至关重要的。如果不这样做,编译器就找不到它们。将 I/O 操作符移动到 parser
**名称空间,看到程序现在工作了。**将你的程序与清单 56-11 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <string_view>;
import <vector>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string_view s) : text_{s} {}
token& operator=(std::string_view s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
}
int main()
{
using namespace parser;
using namespace std;
vector<token> tokens{};
ranges::copy(ranges::istream_view<token>(std::cin), back_inserter(tokens));
ranges::copy(tokens, ostream_iterator<token>(cout, "\n"));
}
Listing 56-11.Move the I/O Operators into the parser Namespace
要查看编译器如何扩展其 ADL 搜索,修改程序,将容器从 vector
更改为 map
,并统计每个标记的出现次数。(还记得探索 23 ?)因为一个map
存储了pair
对象,所以编写一个输出操作符来打印成对的令牌和计数。这意味着ostream_iterator
用来自名称空间std
的两个参数调用<<
操作符。尽管如此,编译器还是找到了您的操作符(在parser
名称空间中),因为std::pair
的模板参数在parser
中。你的程序可能最终看起来类似于清单 56-12 。
import <algorithm>;
import <iostream>;
import <iterator>;
import <map>;
import <string>;
import <string_view>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string_view s) : text_{s} {}
token& operator=(std::string_view s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
// To store tokens in a map.
bool operator<(token const& a, token const& b)
{
return a.text() < b.text();
}
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
std::ostream& operator<<(std::ostream& out,
std::pair<const token, long> const& count)
{
out << count.first.text() << '\t' << count.second << '\n';
return out;
}
}
int main()
{
using namespace parser;
using namespace std;
map<token, long> tokens{};
token tok{};
while (cin >> tok)
++tokens[tok];
ranges::copy(tokens,
ostream_iterator<pair<const token, long>>(cout));
}
Listing 56-12.Counting Occurrences of Tokens
现在您已经了解了模板和名称空间,是时候看看它们的一些实际用途了。接下来的几个探索将从标准容器开始,更仔细地研究标准库的各个部分。
五十七、容器
到目前为止,您使用的唯一标准容器是vector
和map
。我在探索 9 和探索 46 中提到过array
但从未深入。这个探索介绍了剩余的容器,并讨论了容器的一般性质。当第三方库实现附加容器时,它们通常遵循标准库设置的模式,并使它们的容器遵循相同的要求。
容器的属性
容器类型实现了熟悉的数据结构,比如树、列表、数组等等。它们都有一个共同的目的,即在一个容器对象中存储一组相似的对象。您可以将容器视为单个实体:比较、复制、分配等等。您还可以访问容器中的单个项目。一种容器类型与另一种容器类型的区别在于容器在其中存储项目的方式,这反过来会影响访问和修改容器中项目的速度。
标准容器分为两大类:顺序容器和关联容器。不同之处在于,您可以控制序列容器中的项目顺序,但不能控制关联容器中的项目顺序。因此,关联容器为访问和修改其内容提供了改进的性能。标准的序列容器有array
(固定大小)deque
(双端队列)forward_list
(单链表)list
(双链表)vector
(变长数组)。forward_list
类型的工作方式不同于其他容器(由于单链表的性质),它是专门用于特殊用途的。本书不涉及forward_list
,但你可以在任何 C++ 参考资料中找到。
关联容器有两个子类别:有序的和无序的。有序容器按照数据相关的顺序存储键,这是由<
操作符或调用者提供的仿函数给出的。尽管该标准没有指定任何特定的实现,但复杂性要求很大程度上要求有序关联容器作为平衡二叉树来实现。无序容器将键存储在哈希表中,因此顺序对您的代码来说并不重要,并且会随着您向容器中添加项而发生变化。
划分关联容器的另一种方法是划分为集合和贴图。集合就像数学集合:它们有成员,并且可以测试成员资格。映射就像存储键/值对的集合。集合和映射可能需要唯一的关键字,也可能允许重复的关键字。集合类型有set
(唯一键,已排序)multiset
(重复键,已排序)unordered_set
和unordered_multiset
。映射类型有map
、multimap
、unordered_map
和unordered_multimap
。
不同的容器有不同的特性。例如,vector
允许快速访问任何项目,但在中间插入会很慢。另一方面,list
提供了对任何项目的快速插入和删除,但只提供双向迭代器,不提供随机访问。
C++ 标准根据复杂性来定义容器特征,复杂性是用 big-O 符号写的。请记住,在你的算法入门课程中, O (1)是常量复杂度,但没有任何常量可能是什么的指示。 O ( n )是线性复杂度:如果容器有 n 个项目,执行一次 O ( n )操作所花费的时间与 n 成正比。对排序数据的操作往往是对数的: O (log n )。
表 57-1 总结了所有的容器及其特性。插入、删除和查找列显示了这些操作的平均复杂度,其中 N 是容器中元素的数量。查找序列容器意味着查找特定索引处的项目。对于关联容器,这意味着通过值查找特定的项。“否”表示容器根本不支持该操作。
表 57-1。
集装箱及其特征概述
|类型
|
页眉
|
插入
|
抹去
|
检查
|
迭代程序
|
| — | — | — | — | — | — |
| array
| <array>
| 不 | 不 | O① | 接触的 |
| deque
| <deque>
| O ( N )* | O ( N )* | O① | 随机存取 |
| forward_list
| <forward_list>
| O① | O① | O ( N | 向前 |
| list
| <list>
| O① | O① | O ( N | 双向的 |
| map
| <map>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| multimap
| <map>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| multiset
| <set>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| set
| <set>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| unordered_map
| <unordered_map>
| O① | O① | O① | 向前 |
| unordered_multimap
| <unordered_map>
| O① | O① | O① | 向前 |
| unordered_multiset
| <unordered_set>
| O① | O① | O① | 向前 |
| unordered_set
| <unordered_set>
| O① | O① | O① | 向前 |
| vector
| <vector>
| O ( N )* | O ( N )* | O① | 接触的 |
*** 复杂度在容器中间插入和擦除为 O(N)但在容器末端为 O(1),当在许多操作中摊销时。deque 还允许在容器的开头进行摊销的 O(1)插入和擦除。
成员类型
每个容器都提供了许多有用的类型和类型定义作为容器的成员。本节经常使用其中的几个:
值类型
这是容器存储的类型的同义词。例如,vector<double>
的value_type
是double
,std::list<char>::value_type
是char
。使用标准成员类型使得编写和读取容器代码更加容易。本探索的其余部分广泛使用了value_type
。
映射的容器存储键/值对,所以map<Key, T>
(以及multimap
、unordered_map
和unordered_multimap
)的value_type
是std::pair<const Key, T>
。关键字类型是const
,因为在向关联容器添加一个项目后,您不能更改关键字。容器的内部结构取决于键,因此更改键会违反排序约束。
密钥类型
关联容器将key_type
声明为第一个模板参数的 typedef 例如,map<int, double>::key_type
是int
。对于器械包类型,key_type
和value_type
相同。
参考
这是引用value_type
的同义词。除了极少数情况,reference
与value_type&
相同。
常量 _ 引用
const_reference
是引用const value_type
的同义词。除了极少数情况,const_reference
与value_type const&
完全相同。
迭代程序
这是迭代器类型。它可能是 typedef,但更有可能是一个类,其定义是依赖于实现的。重要的是这种类型满足迭代器的要求。每个容器类型实现一个特定的迭代器类别,如表 57-1 所述。
常量迭代器
const_iterator
是const
项的迭代器类型。它可能是 typedef,但更有可能是一个类,其定义是依赖于实现的。重要的是这种类型符合const
项的迭代器的要求。每个容器类型实现一个特定的迭代器类别,如表 57-1 所述。
尺寸 _ 类型
size_type
是内置整数类型之一的 typedef(具体是哪种取决于实现)。它表示序列容器或容器大小的索引。
什么可以放进集装箱
为了在容器中存储项目,项目的类型必须满足一些基本要求。您必须能够复制或移动项目,并使用复制或移动来指定它。对于内置类型,这是自动的。对于一个类类型,你通常有这个能力。编译器甚至为您编写了构造器和赋值操作符。到目前为止,本书中的所有类都满足基本要求;在探索之前,你不必关心不一致的类。
序列容器本身不必比较项目是否相等;他们只是根据需要复制或移动元素。当他们不得不复印时,他们认为复印件和原件是一样的。
有序关联容器需要一个排序函子。默认情况下,它们使用一个名为std::less<key_type>
的标准仿函数,该仿函数又使用<
操作符。你可以提供一个定制的仿函数,只要它实现了严格弱排序,它由以下需求定义:
-
如果 a < b 和b<c,那么 a < c 。
-
一个 <一个一个总是假的。
-
项目存储在容器中后,顺序不会改变。
新 C++ 程序员的一个常见错误是违反规则 2,通常是通过实现<=
而不是<
。违反严格的弱排序规则会导致未定义的行为。一些库有一个调试模式,检查你的仿函数以确保它是有效的。如果你的库有这样的模式,使用它。
无序关联容器需要一个散列函子和一个相等函子。默认的哈希函子是std::hash<key_type>
(在<functional>
中声明)。标准库为内置类型和string
提供了特化。如果你在一个无序的容器中存储一个定制类,你必须提供你自己的散列函子。最简单的方法就是特化hash
。清单 57-1 展示了如何将hash
特化为rational
类型。您只需提供函数调用操作符,该操作符必须返回类型std::size_t
(一个实现定义的整数类型)。
import <functional>;
import rational;
namespace std {
template<class T>
class hash<rational<T>>
{
public:
std::size_t operator()(rational<T> const& r)
const
{
return hasher_(r.numerator() * r.denominator());
}
private:
std::hash<T> hasher_;
};
} // end of std
Listing 57-1.Specializing the hash Template for the rational Type
尽管标准库为所有内置类型提供了一个std::hash<>
特化,但它没有提供任何有效的方法来组合多个哈希值。清单 57-1 中显示的方法没有给出好的结果。(例如,1/2 和 2 共享相同的哈希值。)但是编写有效的哈希函数不在本书的讨论范围之内;有关编写更好的散列函数的信息,请访问该书的网站。
默认的等式仿函数是std::equal_to<T>
(在<functional>
中声明),它使用了==
操作符。如果两个项目相等,它们的哈希值也必须相等(但反过来就不一定了)。
当您在容器中插入项目时,容器会保留该项目的副本,或者您可以将对象移动到容器中。当您抹掉一个项目时,容器会销毁该项目。当你破坏一个容器时,它破坏了它的所有元素。下一节将详细讨论插入和擦除。
插入和擦除
我已经展示了一些在矢量和映射中插入和删除元素的例子。本节将更深入地探讨这个主题。除了array
和forward_list
之外,容器类型遵循一些基本模式。array
类型的大小是固定的,所以它不提供插入或擦除功能。而forward_list
有自己的做法,因为单链表不能直接插入或擦除项。所有其他容器都遵循本节中描述的规范。
在序列容器中插入
您可以选择几个成员函数来将项目插入到序列容器中。最基本的函数是emplace
,它在容器中构造一个条目。它具有以下形式:
-
迭代器就位(此处 const_iterator,args…)
-
将一个新构造的项插入到集合中的位置
here
之前,并返回一个指向新添加项的迭代器。args
可以是零个或多个传递给value_type
构造器的参数。如果here
是end()
,则在容器的末尾构造项目。 -
参考就位 _ 返回(args…)
-
将新构造的项追加到集合中,并返回对该构造项的引用。
args
可以是零个或多个传递给value_type
构造器的参数。允许快速插入集装箱前端的集装箱(deque
、list
)还有emplace_front()
。
如果有已经构造好的对象,调用insert
,它有四种重载形式:
-
迭代器插入(这里是 const_iterator,value_type item)
-
通过将
item
复制或移动到集合中紧靠位置here
之前的位置来插入它,并返回一个引用新添加的项的迭代器。如果here
是end()
,那么item
被追加到容器的末尾。 -
迭代器插入(这里是 const_iterator,size_type n,value_type const &项)
-
在
here
引用的位置之前插入item
的副本n
。如果here
是end()
,则项目被追加到容器的末尾。返回第一个插入项的迭代器。 -
迭代器插入(这里是 const_iterator,STD::initializer _ listbrace _ enclosed _ list)
-
初始化列表是花括号中的值列表。编译器构造一个值范围,这个函数将这些值复制到容器中,从紧接在
here
之前的位置开始。返回第一个插入项的迭代器。 -
模板<类输入器>
迭代器插入(这里是 const_iterator,首先是 InputIterator,最后是 input iterator)
-
从紧接在
here
之前的位置开始,将范围first
、last
中的值复制到容器中。返回第一个插入项的迭代器。
从序列容器中擦除
函数的作用是擦除或删除容器中的项目。序列容器实现了两种形式的erase
:
-
迭代器擦除(const_iterator pos)
-
删除
pos
引用的项目,并返回一个引用后续项目的迭代器。如果最后一项被删除,则返回end()
。如果你试图删除end()
或者pos
是一个不同容器对象的迭代器,行为是未定义的。 -
迭代器擦除(const_iterator first,const_iterator last)
-
删除范围[
first
,last
]中的所有项,并返回一个迭代器,该迭代器指向紧跟在最后一个被删除项之后的项。如果容器中的最后一项被删除,则返回end()
。如果迭代器顺序错误或者引用了不同的容器对象,则行为是未定义的。
函数从容器中删除所有元素。除了基本的擦除功能,序列容器还提供pop_front
来擦除集合的第一个元素,提供pop_back
来擦除集合的最后一个元素。只有当容器能够以恒定的复杂性实现这两个功能时,它才能实现这两个功能。哪些序列容器实现了 pop_back
?
哪些序列容器实现了 pop_front
?
与就位功能一样,vector
提供pop_back
,list
和deque
同时提供pop_back
和pop_front
。
在关联容器中插入
关联容器的所有插入函数都遵循返回类型的通用模式。重复键容器(multimap
、multiset
、unordered_multimap
、unordered_multiset
)为新添加的项返回一个迭代器。唯一键容器(map
、set
、unordered_map
、unordered_set
)返回一个pair<iterator, bool>
:迭代器引用容器中的项目,如果项目被添加,则bool
为 true,如果项目已经存在,则bool
为 false。在本节中,返回类型显示为返回。如果该项目已经存在,则现有项目保持不变,新项目被忽略。
通过调用两个定位函数之一,在关联容器中构造一个新项:
-
返回
emplace(args...)
-
在容器中的正确位置构造一个新元素,将
args
传递给value_type
构造器。 -
iterator emplace_hint(iterator hint, args...)
-
构造一个尽可能靠近
hint
的新元素,将args
传递给value_type
构造器。 -
对于有序容器,如果该项的位置紧接在
hint
之后,则该项以恒定的复杂度添加。否则复杂度是对数的。如果您必须在一个有序容器中存储许多项,并且这些项已经按顺序排列,那么您可以通过使用最近插入的项的位置作为提示来节省一些时间。
与序列容器一样,您也可以调用insert
函数来插入到关联容器中。与序列容器的一个关键区别是,您不必提供位置(有一种形式允许您提供位置作为提示)。
-
返回 插入(value_type 项)
-
将
item
移动或复制到容器中。 -
迭代器插入(const_iterator 提示,value_type 项)
-
将
item
移动或复制到尽可能靠近hint
的容器中,如前面关于emplace_hint
的描述。 -
模板<类输入器>
void insert(先输入,后输入)
-
将范围[
first
,last
]中的值复制到容器中。对于有序容器,如果范围[first
,last
]已经排序,您将获得最佳性能。同样,没有范围形式的插入。
写一个程序,从标准输入中读取一串字符串到一组字符串中。使用emplace_hint
功能。保存返回值,以便在插入下一项时作为提示传递。找到一个大的字符串列表作为输入。将列表复制两份,一份按排序顺序,一份按随机顺序。(如果您需要帮助查找或准备输入文件,请访问本书的网站。)比较你的程序读取两个输入文件的性能。
编写相同程序的另一个版本,这次使用简单的单参数 emplace function
**。**再次用两个输入文件运行程序。比较所有四种变体的性能:有提示的和无提示的插入,排序的和未排序的输入。
清单 57-2 显示了使用emplace_hint
的程序的简单形式。
import <iostream>;
import <set>;
import <string>;
int main()
{
std::set<std::string> words{};
std::set<std::string>::iterator hint{words.begin()};
std::string word{};
while(std::cin >> word)
hint = words.emplace_hint(hint, std::move(word));
std::cout << "stored " << words.size() << " unique words\n";
}
Listing 57-2.Using a Hint Position when Inserting into a Set
当我用一个超过 200,000 字的文件运行程序时,带有排序输入的提示程序在大约 1.6 秒内执行。未打印的表格需要 2.2 秒。在随机输入的情况下,两个程序的运行时间约为 2.3 秒。正如您所看到的,当输入已经排序时,提示会产生影响。细节取决于库的实现;您的里程可能会有所不同。
基于节点的容器(set
、map
、list
、multiset
和multimap
)允许您从一个容器中提取节点,并将它们添加到另一个容器中。有关这些高级功能的详细信息,请参考语言参考。
从关联容器中擦除
函数的作用是擦除或删除容器中的项目。关联容器实现了三种形式的erase
:
-
迭代器擦除(const_iterator pos)
-
删除
pos
所指的项目;复杂性是不变的,可能会分摊到许多调用中。返回一个引用后继值的迭代器(或end()
)。如果pos
不是容器的有效迭代器,那么行为是未定义的。 -
迭代器擦除(const_iterator first,const_iterator last)
-
擦除范围[
first
、last
]中的所有项目。返回一个迭代器,该迭代器指向最后一个被擦除项之后的项。如果容器中的最后一项被删除,则返回end()
。如果[first
,last
]不是容器的有效迭代器范围,则行为未定义。 -
迭代器擦除(value_type const &值)
-
从容器中删除所有出现的
value
。返回擦除的项目数,可以为零。
与序列容器一样,clear()
删除容器中的所有元素。
例外
如果抛出异常,容器会尽力保持秩序。异常有两个潜在的来源:容器本身和容器中的项目。大多数成员函数不会抛出无效参数的异常,所以如果容器内存不足,无法插入新项,容器本身的异常最常见的来源是std::bad_alloc
。
如果您尝试将单个项插入到容器中,并且操作失败(可能是因为该项的复制构造器引发了异常,或者容器内存不足),则容器保持不变。
如果您尝试插入多个项,并且其中一个项在插入容器时引发异常(例如,该项的复制构造器引发异常),大多数容器不会回滚更改。只有list
和forward_list
类型回滚到它们的原始状态。其他容器以有效状态离开容器,并且已经成功插入的项目保留在容器中。
当删除一个或多个项目时,容器本身不会抛出异常,但是它们可能必须移动(或者在有序容器的情况下,比较)项目;如果一个项目的移动构造器抛出异常(极不可能的事件),擦除可能是不完整的。但是,无论如何,容器都保持有效状态。
为了使这些保证保持有效,析构函数不能抛出异常。
Tip
永远不要从析构函数中抛出异常。
迭代器和引用
使用容器时,我还没有提到的一个要点是迭代器和引用的有效性。问题是,当您在容器中插入或删除项目时,该容器的部分或全部迭代器可能会无效,并且对容器中项目的引用可能会无效。哪些迭代器和引用无效以及在什么情况下无效的细节取决于容器。
迭代器和引用失效反映了容器的内部结构。例如,vector
将其元素存储在一个连续的内存块中。因此,插入或删除任何元素都会移动更高索引处的所有元素,这将使更高索引处的所有迭代器和对这些元素的引用无效。随着一个vector
的增长,它可能不得不分配一个新的内部数组,这将使那个vector
的所有现存迭代器和引用失效。您永远不知道什么时候会发生这种情况,所以在向vector
添加项目时,最安全的做法是永远不要保留vector
的迭代器或引用。(但是如果您必须保留这些迭代器和引用,请在库引用中查找reserve
成员函数。)
另一方面,list
实现了一个双向链表。插入或删除一个元素只是插入或删除一个节点,对迭代器和对其他节点的引用没有影响。对于所有容器,如果你删除一个迭代器引用的节点,这个迭代器必然会失效,就像对被删除元素的引用必然会失效一样。
实际上,插入和删除元素时必须小心。这些函数通常返回迭代器,您可以用它们来帮助维护程序的逻辑。清单 57-3 显示了一个函数模板erase_unsorted
,它遍历一个容器并为任何大于其前面值的元素调用erase
。它是一个函数模板,可以处理任何满足序列容器要求的类。
template<class Container>
void erase_unsorted(Container& cont)
{
auto prev{cont.end()};
auto next{cont.begin()};
while (next != cont.end())
{
// invariant: std::is_sorted(cont.begin(), prev);
if (prev != cont.end() and *next < *prev)
next = cont.erase(next);
else
{
prev = next;
++next;
}
}
}
Listing 57-3.Erasing Elements from a Sequence Container
注意erase_less
如何在容器中移动迭代器iter
。prev
迭代器引用前一项(或者container.end()
,当循环第一次开始并且没有前一项时)。只要*prev
小于*iter
,就通过将prev
设置为iter
并增加iter
来推进循环。如果容器按升序排列,则不会发生任何变化。然而,如果项目不在适当的位置,则*prev < *iter
为假,并且位置iter
处的项目被擦除。erase
返回的值是一个迭代器,该迭代器引用了iter
被擦除之前的项。这正是我们想要iter
指向的地方,所以我们只需将iter
设置为返回值,并让循环继续。
编写一个测试程序,看看 erase_unsorted
**如何处理一个列表和一个向量。确保它适用于升序数据、降序数据和混合数据。**清单 57-4 显示了我的简单测试程序。
import <algorithm>;
import <iostream>;
import <initializer_list>;
import <iterator>;
import <ranges>;
import <vector>;
import erase_unsorted; // Listing 57-3
/// Print items from a container to the standard output.
template<class Container>
requires std::ranges::range<Container>
void print(std::string const& label, Container const& container)
{
std::cout << label;
using value_type = std::ranges::range_value_t<Container>;
std::ranges::copy(container,
std::ostream_iterator<value_type>(std::cout, " "));
std::cout << '\n';
}
/// Test erase_unsorted by extracting integers from a string into a container
/// and calling erase_unsorted. Print the container before and after.
/// Double-check that the same results obtain with a list and a vector.
void test(std::initializer_list<int> numbers)
{
std::vector<int> data{numbers};
erase_unsorted(data);
if (not std::is_sorted(data.begin(), data.end()))
print("FAILED", data);
}
int main()
{
test({2, 3, 7, 11, 13, 17, 23, 29, 31, 37});
test({37, 31, 29, 23, 17, 13, 11, 7, 3, 2});
test({});
test({42});
test({10, 30, 20, 40, 0, 50});
}
Listing 57-4.Testing the erase_unsorted Function Template
erase_unsorted
函数的净效果是让容器保持有序。所以test()
函数调用std::is_sorted
来验证这个函数确实被排序了。如果没有,它会打印一条消息和一个用于调试的数字列表。这些测试包括已经按顺序排列的数字序列(包括一元序列和空序列)、逆序序列和混合序列。
序列容器
在本书中,容器最常见的用法是在vector
的末尾添加条目。然后,程序可能会使用标准算法来改变顺序,比如按升序排序、按随机顺序洗牌等等。除了vector
,其他的序列容器还有array
、deque
和list
。
序列容器的主要区别在于它们的复杂性特征。如果你经常需要从序列中间插入和删除,你可能需要list
。如果只需插入和擦除一端,使用vector
。如果容器的大小是一个固定的编译时常量,使用array
。如果序列的元素必须连续存储(在单个内存块中),使用array
或vector
。
以下各节包括了关于每种容器类型的更多细节。每个部分都提供了相同的程序进行比较。该程序构建一副扑克牌,然后随机选择一张牌给自己,一张牌给你。价值最高的牌获胜。程序播放十次,然后退出。该程序无需替换即可玩,也就是说,它不会在每次游戏结束后将用过的牌放回牌堆。为了随机挑选一张牌,程序使用了清单 45-5 中的randomint
类。将类别定义保存在名为randomint.hpp
的文件中,或者从书籍网站下载该文件。清单 57-5 显示了示例程序使用的card
类。关于完整的类定义,请从该书的网站下载card.hpp
。
export module cards;
import <iosfwd>;
/// Represent a standard western playing card.
export class card
{
public:
using suit = char;
static constexpr suit const spades {4};
static constexpr suit const hearts {3};
static constexpr suit const clubs {2};
static constexpr suit const diamonds {1};
using rank = char;
static constexpr rank const ace {14};
static constexpr rank const king {13};
static constexpr rank const queen {12};
static constexpr rank const jack {11};
constexpr card() : rank_{0}, suit_{0} {}
constexpr card(rank r, suit s) : rank_{r}, suit_{s} {}
constexpr void assign(rank r, suit s);
constexpr suit get_suit() const { return suit_; }
constexpr rank get_rank() const { return rank_; }
private:
rank rank_;
suit suit_;
};
export bool operator==(card a, card b);
export bool operator!=(card a, card b);
export std::ostream& operator<<(std::ostream& out, card c);
export std::istream& operator>>(std::istream& in, card& c);
/// In some games, Aces are high. In other Aces are low. Use different
/// comparison functors depending on the game.
export bool acehigh_less(card a, card b);
export bool acelow_less(card a, card b);
/// Generate successive playing cards, in a well-defined order,
/// namely, 2-10, J, Q, K, A. Diamonds first, then Clubs, Hearts, and Spades.
/// Roll-over and start at the beginning again after generating 52 cards.
export class card_generator
{
public:
card_generator();
card operator()();
private:
card card_;
}
;
Listing 57-5.The card Class, to Represent a Playing Card
数组类模板
array
类型是固定大小的容器,所以不能调用insert
或erase
。要使用array
,请将基本类型和大小指定为编译时常量表达式,如下所示:
std::array<double, 5> five_elements;
如果用比数组大小更少的值初始化数组,剩余的值将被初始化为零。如果完全省略了初始值设定项,如果值类型是类类型,编译器将调用默认初始值设定项;否则,它保持数组元素未初始化。因为一个数组不能改变大小,所以你不能在玩完牌后简单地把牌擦掉。为了保持代码简单,程序会在每次游戏结束后将卡片放回卡片组。清单 57-6 显示了替换的大牌程序。
import <algorithm>;
import <array>;
import <iostream>;
import card;
import randomint; // Listing 45-5
int main()
{
std::array<card, 52> deck;
std::ranges::generate(deck, card_generator{});
randomint picker{0, deck.size() - 1};
for (int i{0}; i != 10; ++i)
{
card const& computer_card{deck.at(picker())};
std::cout << "I picked " << computer_card << '\n';
card const& user_card{deck.at(picker())};
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-6.Playing High-Card with array
deque 类模板
一个deque
(读作“deck”)代表一个双端队列。从开头或结尾插入和删除速度很快,但是如果必须在其他地方插入或删除,复杂度是线性的。大多数时候,你可以像使用vector
一样使用deque
,所以应用你使用 vector
**的经验来编写大牌程序。**不替换玩法:即每局游戏结束后,通过将两张牌从容器中擦除的方式将其丢弃。清单 57-7 展示了我如何使用deque
编写大牌程序。
import <algorithm>;
import <deque>;
import <iostream>;
import card;
import randomint;
int main()
{
std::deque<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin() + randomint{0, deck.size()-1}()};
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin() + randomint{0, deck.size() - 1}();
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-7.Playing High-Card with a deque
列表类模板
一个list
代表一个双向链表。在列表中的任何点插入和擦除都很快,但不支持随机访问。因此,high-card 程序使用迭代器和advance
函数(探索 46 )。编写高卡程序使用 list
。将你的解决方案与清单 57-8 中我的解决方案进行比较。
import <algorithm>;
import <iostream>;
import <list>;
import card;
import randomint;
int main()
{
std::list<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = std::next(deck.begin(), randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-8.Playing High-Card with a list
deque
类型支持随机访问迭代器,所以它可以给begin()
加一个整数来挑选一张牌。但是list
使用双向迭代器,所以必须调用advance()
或者next()
;清单 57-8 展示了这两者。注意,您也可以为deque
s 调用advance()
或next()
,并且实现仍然使用加法。
vector 类模板
vector
是一个可以在运行时改变大小的数组。追加到末尾或从末尾擦除速度很快,但在矢量中的任何其他位置插入或擦除时,复杂度是线性的。对比 deque
和 list
版本的高卡程序。选择您喜欢的一个并修改它以与 vector
一起工作。清单 57-9 中显示了我的程序版本。
import <algorithm>;
import <iostream>;
import <vector>;
import card;
import randomint;
int main()
{
std::vector<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin() + randomint{0, deck.size()-1}()};
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin() + randomint{0, deck.size() - 1}();
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-9.Playing High-Card with vector
注意你如何改变程序来使用vector
而不是deque
,仅仅通过改变类型名。它们的用法非常相似。一个关键的区别是deque
在容器的开头提供快速(恒定复杂度)插入,这是vector
所缺乏的。另一个关键区别是vector
支持连续迭代器,而 deque 使用随机访问迭代器。这两个因素在这里都不重要。
关联容器
关联容器通过控制容器中元素的顺序来提供快速插入、删除和查找。有序关联容器将元素存储在树中,由比较仿函数(默认为std::less
,它使用<
)排序,因此插入、删除和查找以对数复杂度发生。无序容器使用哈希表(根据调用者提供的哈希函子和等式函子)进行访问,在一般情况下具有恒定的复杂性,但在最坏情况下具有线性复杂性。有关树和散列表的更多信息,请查阅任何关于数据结构和算法的教科书。
设置存储键,并映射存储键/值对。多重集和多重映射允许重复键。所有等价密钥都存储在容器中的相邻位置。普通集合和映射需要唯一的键。如果您尝试插入容器中已经存在的密钥,则不会插入新密钥。请记住,有序容器中的等价仅由调用比较函子来确定:compare(a, b)
为假,compare(b, a)
为假意味着a
和b
等价。
无序容器调用它们的相等函子来确定一个键是否重复。默认为std::equal_to
(在<functional>
中声明),使用==
操作符。
因为关联数组存储键的顺序取决于键的内容,所以不能修改存储在关联容器中的键的内容。这意味着你不能使用关联容器的迭代器作为输出迭代器。因此,如果您想使用关联容器实现 high-card 程序,您可以使用inserter
函数创建一个填充容器的输出迭代器。清单 57-10 展示了如何使用set
来实现高卡程序。
import <algorithm>;
import <iostream>;
import <iterator>;
import <set>;
import <utility>;
import card;
import randomint;
int main()
{
using cardset = std::set<card, std::function<bool(card, card)>>;
cardset deck(acehigh_less);
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-10.Playing High-Card with set
使用关联容器时,当您使用自定义比较仿函数(对于有序容器)或自定义相等和散列仿函数(对于无序容器)时,可能会遇到一些困难。您必须将函子类型指定为模板参数。构造容器对象时,将仿函数作为参数传递给构造器。函子必须是您在模板特化中指定的类型的实例。
例如,清单 57-10 使用了acehigh_less
函数,并将其传递给deck
的构造器。因为acehigh_less
是一个函数,所以必须指定一个函数类型作为模板参数。声明函数类型最简单的方法是使用std::function
模板。模板参数看起来有点像一个无名的函数头—提供返回类型和参数类型:
std::function<bool(card, card)>
另一种方法是特化类型card
的std::less
类模板。显式特化将实现函数调用操作符来调用acehigh_less
。利用特化,您可以使用默认的模板参数和构造器参数。遵循<functional>
标题中的函子模式。函子应该提供一个函数调用操作符,该操作符使用参数和返回类型,并为容器实现严格的弱排序函数。清单 57-11 展示了另一个版本的大牌程序,这次使用了less
的特化。唯一真正的区别是如何初始化甲板。
import <algorithm>;
import <functional>;
import <iostream>;
import <iterator>;
import <set>;
import card;
import randomint;
namespace std
{
template<>
class less<card>
{
public:
bool operator()(card a, card b) const { return acehigh_less(a, b); }
};
}
int main()
{
using cardset = std::set<card>;
cardset deck{};
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-11.Playing High-Card Using an Explicit Specialization of std::less
要使用无序容器,必须编写一个显式的std::hash<card>
特化。清单 57-1 应该能帮上忙。卡模块已经为卡声明了operator==
,所以你要准备好最后一次重写高卡程序,这次是为 unordered_set
**。**将您的解决方案与清单 57-12 进行比较。尽管容器类型不同,但所有这些程序都非常相似,这是通过明智地使用迭代器、算法和函子而实现的。
import <algorithm>;
import <functional>;
import <iostream>;
import <iterator>;
import <unordered_set>;
import card;
import randomint;
namespace std
{
template<>
class hash<card>
{
public:
std::size_t operator()(card a)
const
{
return hash<int>{}(a.get_suit() * 64 + a.get_rank());
}
};
} // namespace std
int main()
{
using cardset = std::unordered_set<card>;
cardset deck{};
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i(0); i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-12.Playing High-Card with unordered_set
在接下来的探索中,你将踏上一个完全不同的旅程,一个涉及到异国情调的地方,当地人说异国情调的语言,使用异国情调的字符集的世界旅行。这个旅程还涉及到模板的新的有趣的用途。