本章涵盖
- 编写类或结构
- 作用域枚举
- 使用 array 而不是 vector 当我们知道需要多少个元素时
- 编写比较运算符
- 默认函数
- 使用 std::variant
在本章中,我们将创建一副牌并编写一个高低牌游戏,以猜测从牌堆中抽出的下一张牌是高还是低。我们将为一张牌创建一个类,并在一个 array 中存储一副牌。我们需要考虑如何为我们的牌定义比较运算符,以及如何编写构造函数和其他成员函数。我们还需要使用随机洗牌。然后,我们将扩展游戏以包括王牌,并学习如何使用 std::variant 。到本章结束时,我们将拥有一个可工作的纸牌游戏,并准备好进一步使用类。
5.1 创建一副扑克牌
我们将首先定义一个卡片类。我们可以使用关键字 class 或 struct 来声明一个卡片。如果我们使用 struct ,默认情况下所有内容都是公共的,这是一个简单的起点。卡片有花色和数值。每种花色有四种,且每种花色有 13 个可能的数值:1 或王牌;2 到 10;以及三张人头牌。我们还需要显示和比较卡片,以及创建一整副牌的方法。我们将从卡片本身开始。
到目前为止,我们已经将所有代码放在一个 main.cpp 文件中。在本章中,我们将创建一个名为 playing_cards.h 的头文件,并将其包含在我们的 main.cpp 中。随着我们添加函数,大多数函数将放入一个 playing_cards.cpp 源文件中。让我们花一点时间回顾一下使用源文件和头文件的基本知识。当我们使用头文件时,我们始终需要一个包含保护。这可以防止头文件在同一个源文件中被包含多次,这可能会导致问题,包括违反单一定义规则。如果没有保护,包含同一个头文件两次(如果一个头文件间接包含另一个头文件,这很容易发生)意味着枚举、结构等将被定义两次,这是不允许的。这并不是什么新鲜事。CppReference 提供了更多关于这个主题的细节(http://mng.bz/z0Rg)。有些人仍然使用宏来进行包含或头文件保护,选择一个唯一的名称。
清单 5.1 宏风格的包含保护
#ifndef PLAYING_CARDS_HEADER
#define PLAYING_CARDS_HEADER
...
#endif
然而, pragma 指令 once 现在得到了广泛支持。如果该指令在您的编译器上不起作用,使用宏版本也是可以的。让我们为我们的卡片创建一个命名空间,将结构和函数保留在 namespace 范围内。
清单 5.2 playing_cards 头文件
#pragma once ?
namespace cards ?
{
}
? 包含保护器
? 后续声明的命名空间
最后,我们在 main.cpp 中包含这个头文件,使用引号 "" 而不是尖括号 <> ,这表明它是我们的而不是库头文件。包含头文件的搜索具体位置是由实现定义的,但引用版本会在尖括号版本初始搜索失败时搜索的地方进行搜索。人们通常使用尖括号来表示标准库头文件,而使用引号来表示他们自己的头文件。我们的主函数目前还没有做太多,但我们现在有地方可以放置我们的代码。
清单 5.3 包含头文件
#include "playing_cards.h" ?
int main()
{
}
包含我们的标题
我们现在准备为我们的游戏制作一些扑克牌。
5.1.1 使用作用域枚举定义卡片类型的花色
我们知道我们需要每张扑克牌的花色和数值。我们可以最初使用整数作为数值,尽管我们也可以使用整数作为花色,但使用 enum 会更清晰。C++11 引入了作用域枚举,它们看起来与旧的无作用域 enum 非常相似,但在 enum 关键字和名称之间有 class 这个词,或者等效地, struct 。在 playing_cards.h 文件中添加一个 enum ,每个花色一个枚举器,放在命名空间内。
清单 5.4 适用范围 enum 适用于诉讼
#pragma once ?
namespace cards ?
{
enum class Suit { ?
Hearts,
Diamonds,
Clubs,
Spades
};
}
? 包含保护器
? 命名空间
注意词类。
添加单词 class 的微小变化会产生很大差异。没有它,我们就有了旧式的 enum ,可以使用 Hearts 或其他任何值,或枚举,而不需要资格。这意味着我们可能会错误地比较来自不同枚举的值。如果使用两个不同的枚举来指示一个函数是否成功,它们可能都用 OK 表示成功,并用许多值中的一个表示失败。这样就可能检查结果是否为 OK,从而混淆这两个不同的枚举。为了使用我们的套件,我们需要说 Suit::Hearts ,使潜在的无意比较变得不可能。
作用域枚举的值与整型之间没有隐式转换,而旧的枚举是可以的。如果我们想将值作为数字使用,则需要显式地使用强制转换。作用域枚举更安全。
我们首先创建一个 struct 来保存值,并适合在头文件的命名空间内的初始卡片类型。
列表 5.5 卡片结构
struct Card
{
int value;
Suit suit;
};
我们可以在 main 中创建一个具有值和花色的卡片,前提是我们包含我们的头文件并使用 cards 命名空间。我们将使用聚合初始化,这看起来与使用初始化列表的统一初始化非常相似。我们在第 2 章中使用了这个来构建帕斯卡三角形的第一行: std::vector
清单 5.6 使用 Card 结构体
#include "playing_cards.h" ?
using namespace cards; ?
int main()
{
Card card{2, Suit::Clubs}; ?
}
包含我们的标题
使用命名空间
? 创建一个具有数值和花色的卡片
我们可以只指定值 Card card{2} ,而套件将默认初始化为第一个 enum 值。然而,我们不能说 Card card{Suit::Clubs} 。我们可以在末尾省略初始化器,但不能在开头省略。
如果我们没有为套件使用一个作用域 enum ,我们将需要使用两个 int 来制作一张卡片,并且必须记住哪个是哪个。使用 card{2, Suit::Clubs} 比 card{2, 3} 更清晰且更不容易出错。不过,目前我们可以使用 0 或 14 作为卡片的面值。我们在上一章中通过使用 year_month_day 学习了整个值的习语。我们现在可以通过为卡片的值创建一个类型来采用相同的想法,并确保只使用 1 到 13 之间的值。除了验证所使用的值外,我们还将看到如何使用该类型轻松显示卡片。
5.1.2 使用强类型定义面值的卡片类型
面值需要接受一个 int 并存储它,提供一个获取函数,以便代码在需要时可以使用该值。在最后一章中,我们考虑了整体值习语,以创建轻量级类型以确保参数正确传递。如果我们创建一个 FaceValue 类并使用 explicit 构造函数,我们不能在函数需要面值的地方传递一个 int 。例如,如果我们有一个签名为的函数
void do_something_with_face_value(const cards::FaceValue & value);
我们不能用 int 来调用它。相反,我们需要创建一个面值:
do_something_with_face_value(cards::FaceValue{ 5 });
一个 int 不能隐式转换为我们的新类型,因为构造函数是显式的。
如果使用的值无效,我们将抛出一个异常。来自 stdexcept 头的 std::invalid_argument 异常是有意义的。
列表 5.7 面值类型
#include
namespace cards
{
class FaceValue
{
public:
explicit FaceValue(int value) : value_(value) ?
{
if (value_ < 1 || value_ > 13)
{
throw std::invalid_argument(
"Face value invalid"
); ?
}
}
int value() const
{
return value_;
}
private:
int value_;
};
...
}
显式构造函数
? 验证值
我们可以将 Card 定义中的类型从 int value 更改为 FaceValue value 。要创建一个像我们在列表 5.6 中所做的卡片,我们必须明确地制作一个 FaceValue , Card card{ FaceValue(2), Suit::Clubs} ,而不是能够说 Card card{2, Suit::Clubs} 。在构造卡片时,我们需要稍微多花一点力气,但如果我们正确构造一个卡片,我们将获得一副和一个有效的卡片值。在我们开始使用 FaceValue 之前,我们应该稍微多考虑一下如何制作卡片。事情仍然可能出错。让我们重新审视我们的卡片类型,确保我们只制作有用的扑克牌。
5.1.3 构造函数和默认值
在我们使用我们的 FaceValue 之前,请考虑在列表 5.5 中定义的 Card 类型。我们的结构体有两个成员,一个 int 值和一个 Suit 。我们可以创建一个没有值或花色的卡片:
Card dangerous_card;
然而,这两个成员字段将不会被初始化。如果我们尝试读取这些字段,将会出现未定义行为。在 Visual Studio 2022 中,我恰好在调试构建中得到了 -858993460 的值和 -858993460 的花色。在发布构建中,我可能会得到不同的垃圾值。编译器可以随意处理这样的代码,因此在其他编译器中你可能会得到不同的行为。如果我们使用花括号初始化
Card less_dangerous_card{};
成员是默认初始化的。我们之前见过花括号或统一初始化,记住初始化变量是一个好习惯。我们可以尽量小心不使用未初始化的值,但确保我们无法首先创建危险的扑克牌更安全。我们可以采用多种方法来避免未初始化的成员变量。
最简单的方法是使用默认值来初始化值和套件。自 C++11 以来,我们可以使用默认成员初始化器,直接给我们想要初始化的任何成员赋予默认值。整数默认初始化为 0,枚举初始化为第一个值。
列表 5.8 卡片结构
struct Card
{
int value{}; ?
Suit suit{}; ?
};
? 使用默认值初始化成员
我们之前危险的卡片现在有了值,我们可以安全地读取,给我们一个 0 的红心:这是一张非常不可能的扑克牌,但没有未定义的行为。如果我们现在改用 FaceValue ,我们就无法制作一个值为 0 的卡片,因此我们需要选择一个可接受的值,比如 1。
列表 5.9 卡片结构
struct Card
{
FaceValue value{1}; ?
Suit suit{};
};
? 使用可行的默认值初始化 FaceValue
我们可以使用这个定义来进行我们的游戏,但首先让我们考虑一种替代方法,因为我们仍然存在潜在问题。一个 struct 的成员默认是公开的,这意味着我们可以直接使用它们。因此,我们可以轻松地更改它们的值,这可能不是一个好主意。我们可以将它们标记为私有,或者使用 class 代替 struct ,因为 class 的成员默认是私有的。在这两种情况下,我们需要一种设置值的方法;否则,每张卡片将具有相同的值。我们可以添加一个公共构造函数,接受一个值和一个花色,并存储它们。如果我们需要从外部访问 class 或 struct 的这些值,我们还需要添加获取器。这些应该标记为 const ,因为它们不会改变 Card 成员的值。这允许它们被卡片变量调用,无论它是否是 const 。我们可以更改原始结构的名称,或者将其删除并在命名空间的头文件中创建一个新的改进类型。
清单 5.10 一个卡片类
class Card
{
public:
Card(FaceValue value, Suit suit): ?
value_(value), ?
suit_(suit) ?
{
}
FaceValue value() const { return value_; } ?
Suit suit() const { return suit_; } ?
private:
FaceValue value_; ?
Suit suit_; ?
};
构造函数,接受值和花色
? 存储值和花色
? 获取器,标记为常量
? 私有成员
我们不再能够默认构造一个卡片。由于我们编写了自己的带参数的构造函数,系统不再为我们生成默认构造函数。我们之前创建的危险卡片现在变得不可能。尝试
Card impossible_card;
将无法编译。如果您之前使用过 C++,这也应该很熟悉。
我们需要在使用 std::array 构建一副牌时默认构造卡片。C++11 引入了一种默认默认构造函数的方法。如果我们添加
Card() = default;
对于列表 5.10 中的类,我们的 impossible_card 变得可能。编译器定义了一个默认构造函数,即使我们编写了另一个构造函数。我们仍然应该像之前一样为值和套件添加默认成员初始化器,以便默认构造函数初始化这些。
清单 5.11 一个默认可构造的卡片
class Card
{
public:
Card() = default; ?
Card(FaceValue value, Suit suit):
value_(value),
suit_(suit)
{
}
FaceValue value() const { return value_; }
Suit suit() const { return suit_; }
private:
FaceValue value_{1}; ?
Suit suit_{}; ?
};
? 默认构造函数
? 成员初始化器
我们还可以使用 = delete 将构造函数标记为已删除。这将阻止构造函数的生成。我们可以对任何特殊成员函数执行此操作,例如 copy 或 move 构造函数、赋值运算符或析构函数。在 C++11 之前,我们通常将希望隐藏的函数设为私有,以避免它们被使用。能够声明一个函数已删除要简单得多,并且使我们的意图更加明确。我们将在下一章中更详细地查看特殊成员函数。现在,我们有一个强大的卡片类型。我们需要一种显示卡片的方法;然后我们可以继续创建一副牌并编写我们的游戏。
5.1.4 显示扑克牌
要显示一张卡片,我们希望能够编写
std::cout << card << '\n';
因此,我们需要为我们的 Card 类型提供一个流插入运算符。我们在清单 2.5 中编写了一个流插入运算符。我们需要一个重载,接受对 std::ostream 的引用作为第一个参数,以及对 Card 的常量引用作为第二个参数:
std::ostream& operator<<(std::ostream & os, const Card & card);
我们返回对流的引用,以便可以将调用链接在一起:
std::cout << card << ', ' another_card << '\n';
std::ostream 生活在 iostream 头文件中,因此我们将其包含在内,并将我们的操作符声明添加到命名空间中的头文件中。
清单 5.12 为卡片声明 operator<<
#pragma once
#include ?
namespace cards
{
...
std::ostream& operator<<(std::ostream & os, const Card & card); ?
}
? Includes the header we use
? 声明我们的功能
我们有两个数据成员需要输出。 FaceValue 成员有一个名为 value 的获取器,我们可以用它来输出底层的 int 。一张牌的值将显示为一个数字,即使它是一个王牌或人头牌。我们稍后会对此进行改进。花色是一个作用域 enum ,我们现在也可以将其作为 int 输出。默认情况下,作用域枚举使用 int 作为枚举值,因此我们可以将花色转换为 int ,使用 static_ cast ,并将其输出。我们的头文件承诺在一个命名空间中有一个函数,因此我们在名为 playing_ cards.cpp 的源文件中的 namespace cards 内定义该函数。
清单 5.13 为卡片定义 operator <<
#include "playing_cards.h" ?
namespace cards ?
{
std::ostream& operator<<(std::ostream& os, const Card& card) ?
{
os << card.value().value() ?
<< " of " << static_cast(card.suit()); ?
return os;
}
}
包含我们的标题
在命名空间内添加代码
? 定义功能
获取面值的值
将枚举转换为整数
如果您是根据提示构建此内容,则需要在构建命令中指定两个 cpp 文件:
clang++ --std=c++20 main.cpp playing_cards.cpp -o ./main.out -Wall
凭借 Card card{FaceValue(2), Suit::Clubs} ,我们现在可以写作
std::cout << card << '\n';
并获取 2 of 2 。俱乐部是 enum 中的第三个元素,因此使用零基索引确实给我们俱乐部的值为 2,但看到 2 of Clubs 会更好。
我们可以更新卡片的流操作符,但可能会有一些情况我们只想显示面值或花色。我们可以为每个写一个流操作符,或者我们可以写一个 to_string 方法。C++11 为数值类型添加了 to_string 方法。这些函数位于 string 头文件中。
我们可以编写自己的 to_string 重载,一个用于 Suit ,一个用于 FaceValue 。 Suit 的声明接受一个 Suit 并返回一个 string :
std::string to_string(Suit suit);
与其他声明一样,它属于头文件。我们在头文件中也包含了 string 头文件,因为我们正在使用 std::string 。声明到此为止。我们如何定义函数?在上一章中,我们提到可以使用 operator ""s 来自 std::literals 来制作 std::string 。 "Hearts"s 创建一个 std::string ,而 "Hearts" 是一个字符数组。这并不是什么大问题,但我们正在返回一个字符串,所以让我们创建一个字符串。我们 to_string 函数的最简单方法是使用 switch 语句,配对枚举器和花色。我们添加一个默认值以消除关于没有返回语句的代码路径的潜在警告。
清单 5.14 将枚举值转换为字符串
std::string to_string(const Suit & suit)
{
using namespace std::literals; ?
switch (suit)
{
case Suit::Hearts:
return "Hearts"s; ?
case Suit::Diamonds:
return "Diamonds"s; ?
case Suit::Clubs:
return "Clubs"s; ?
case Suit::Spades:
return "Spades"s; ?
default:
return "?"s;
}
}
? 对于操作员 ""s
直接创建 std::strings
我们可以在最后抛出一个异常,而不是返回一个问号。有很多选择,但这个简单的方法已经足够好了。
注意,Java 和 C# 的枚举支持 ToString 方法,但 C++ 不支持。如果 C++ 有反射,我们可以将枚举值转换为字符串。然而,C++ 目前还不支持反射,但有一个技术规范(简称 TS;请参见
https://www.iso.org/deliverables-all.xhtml)用于编译时或静态反射(http://mng.bz/G9n8)。潜在的 C++ 特性有时会有示例实现,一些编译器也提供实验性头文件,例如
我们现在可以在显示我们创建的卡片时获取 2 of Clubs 。然而,当前的情况是,宫廷牌和王牌将显示为数字。因为我们创建了一个 FaceValue 类型,我们可以编写另一个 to_string 重载,针对宫廷牌和王牌的特殊情况。任何其他值将使用 std::to_string 方法来处理 int 。和往常一样,我们在头文件中声明函数,并在我们的扑克牌源文件中定义它。
清单 5.15 将卡片值转换为字符串
std::string to_string(const FaceValue & value)
{
using namespace std::literals; ?
switch (value.value())
{
case 1:
return "Ace"s; ?
case 11:
return "Jack"s; ?
case 12:
return "Queen"s; ?
case 13:
return "King"s; ?
default:
return std::to_string(value.value()); ?
}
}
? 对于操作员 ""s
直接创建 std::strings
? 2 到 9 作为字符串
我们现在可以更新我们的流插入运算符,以使用我们重载的 to_string 函数
清单 5.16 显示 A、J、Q、K 或数字
std::ostream& operator<<(std::ostream& os, const Card& card)
{
os << to_string(card.value()) ?
<< " of " << to_string(card.suit()); ?
return os;
}
? 使用我们的新功能
如果我们发出一张特殊价值卡
std::cout << Card{ FaceValue(1), Suit::Hearts } << '\n';
我们看到 Ace of Hearts 。我们可以制作单独的卡片,所以现在我们需要制作一副卡片。
5.1.5 使用数组制作一副扑克牌
我们之前使用了一个 vector 当我们想要一个元素集合时。 vector 在我们有一个未知数量的元素时很有用,但我们知道完整的牌组需要 52 张牌。C++11 引入了数组类型 (
https://en.cppreference.com/w/cpp/container/array ) 用于固定大小的数组。它位于 array 头文件中,并通过类型和大小进行定义:
template struct array;
vector 采用了元素类型 T ,但 array 也需要一个编译时大小 N 。向量可以动态调整大小,但数组的大小在编译时固定为所选大小。数组的管理开销非常小,可以放在栈上而不是堆上。这在图 5.1 中进行了说明。
我们的牌组可以被声明为
std::array deck;
我们可以使用 C 风格的数组 Card deck[52] ,但 std::array 让我们更安全,因为我们始终知道数组的大小。在这两种情况下,我们得到 52 张默认构造的卡片。使用 vector 时,我们会 push_back 或 emplace 任何我们需要的新卡片, vector 将会增长。我们可以使用聚合初始化来初始化一些或所有的卡片。因此
std::array deck{Card{FaceValue(2), Suit::Hearts}};
在开始时放置一张红心 2,并对其余 51 张牌使用默认构造函数。我们可以像在 vector 或 C 风格数组中一样访问特定元素,使用 operator[] ,所以 deck[0] 是第一张牌。如果我们需要将我们的 array 传递给一个接受数组类型指针的函数(例如,在 C 库函数中),我们可以调用 data 成员函数以获取指向底层数据的指针。
让我们写一个函数来创建一副牌。我们需要在 array 头文件中包含头文件,声明函数,然后在源文件中定义它。我们需要为四种花色中的每一种提供 13 个值。不幸的是,我们不能简单地遍历 Suit 枚举。没有什么强制要求这些值是连续的,尽管在我们的情况下它们是连续的。因此,使用 operator++ 可能会在一般情况下使用无效的枚举值。我们可以做的是将这些值放入一个 initializer_list 中。当我们讨论统一初始化语法时,我们在第 2 章中使用了花括号初始化。通过制作花色的初始化列表。
{Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades}
我们有一个类似数组的对象,可以在循环中使用。我们需要循环遍历每个花色的 13 个面值。从 array 的开头开始一个迭代器,我们可以使用 *card 设置其内容,并在每次循环中使用 ++card 移动到下一张牌。
清单 5.17 构建一副扑克牌
std::array create_deck()
{
std::array deck;
auto card = deck.begin(); ?
for (auto suit :
{Suit::Hearts, Suit::Diamonds, Suit::Clubs, Suit::Spades}) ?
{
for (int value = 1; value <= 13; value++) ?
{
*card = Card{ FaceValue(value), suit }; ?
++card; ?
}
}
return deck;
}
? 从第一张卡片开始的迭代器
? 套件的初始化列表
? 循环圆值
? 设置卡片的值
? 移动到下一张卡片
我们可以利用目前所拥有的内容制作一款卡牌游戏,但我们注意到在第二章中提到要避免使用原始循环,而更倾向于使用算法。我们可以重构列表 5.17 中的函数,改用算法来创建一副牌。我们在这一章中没有看到任何测试,但 GitHub 代码中包含一个 check_properties 函数,类似于我们在前几章中编写的测试函数。在重构代码之前,考虑一下我们应该测试什么。对于面值为 0 的卡牌,我们会得到异常吗?我们真的有 52 张不同的卡牌吗?
5.1.6 使用 generate 填充数组
algorithm 头文件包含一个名为 generate 的方法,该方法将函数对象生成的连续值分配给范围 [first, last) 。C++20 引入了更新版本,包括适用于范围的重载,因此我们可以直接使用 std::array
std::ranges::generate(deck, []() { return Card{value, suit}; });
我们想要循环遍历从 1 到 13 的值,每种花色各一个值。我们注意到枚举中没有 operator++ ,因为那可能会使用无效的枚举值;因此,我们在列表 5.17 中使用了初始化列表来遍历每个枚举值。让我们考虑一个替代方案,并进一步了解作用域枚举。在我们的情况下,枚举值是连续的,实际上,当我们到达最后一种花色时,我们可以从头开始,这样如果我们想的话,可以使用一个包含 104 张牌的数组来获得两副牌。我们可以将枚举值转换为 int ,使用 static_cast ,因为我们注意到作用域枚举有一个底层类型,默认情况下将是 int 。我们像这样声明了我们的 enum 。
enum class Suit
在列表 5.4 中。如果我们想的话,也可以指定一个类型;例如:
enum class Suit: short
这可能会节省一些空间,如果我们不需要整数的话,我们甚至可以使用 char ,如果我们只有很少的值。或者,如果我们需要一个非常长的枚举列表,我们可以使用 long long 。我们可以使用 underlying_type 来决定在一般情况下要转换成什么,而不是转换为 int 。然后我们可以选择下一个花色,当我们到达末尾时返回开始。
列表 5.18 增加我们的枚举
Suit& operator++(Suit & suit)
{
using IntType = typename std::underlying_type::type; ?
if (suit == Suit::Spades)
suit = Suit::Hearts; ?
else
suit = static_cast(static_cast(suit) + 1); ?
return suit;
}
? 基础枚举类型
? 返回第一套
? 使用铸件增量
这段代码依赖于连续的枚举值,改变枚举的顺序会导致代码出错。然而,值得注意的是作用域枚举的 underlying_type 。
与我们卡片的所有代码一样,我们将功能放在扑克牌源文件中,并在头文件中声明它。我们现在可以生成所需的 array 值。无论我们使用 generate 的范围版本还是 begin / end 版本,我们都需要包含 algorithm 头文件。我们从一的卡片值开始,为每生成一张卡片递增。如果值大于 13,我们将回到一并递增花色。所有这些都发生在一个 lambda 中,因此我们通过引用捕获值和花色,使用 [&value, &suit] 。 generate 函数对牌堆中的每个项目调用 lambda,一次分配生成的卡片给每个元素。
清单 5.19 生成扑克牌
#include
std::array create_deck()
{
std::array deck;
int value = 1; ?
Suit suit = Suit::Hearts; ?
std::ranges::generate(deck, [&value, &suit]() { ?
if (value > 13)
{
value = 1; ?
++suit; ?
}
return Card{FaceValue(value++), suit}; ?
});
return deck;
}
? 从红心 A 开始
通过引用捕获
重置值并增加花色
? Lambda 返回一个卡片并增加值
我们有一副完整的扑克牌,因此我们几乎准备好构建我们的游戏。首先,我们需要能够比较两张牌,以决定哪一张更高或更低。
5.1.7 比较运算符和默认值
一个类型有六种可能的比较:
- 等于 ( ==) )
- 不等于 ( != )
- 小于 ( <)
- 大于 ( > )
- 小于或等于 ( <=) )
- 大于或等于 ( >=) )
C++ 允许我们很久以来编写自己的比较运算符。例如,我们可以在类定义中内联实现一个小于运算符。
清单 5.20 小于运算符用于 Card
bool operator<(const Card& other) const
{
return value < other.value.value() && suit < other.suit;
}
我们可以比较两张卡片:
Card{FaceValue(2), Hearts} < Card{FaceValue(3), Hearts}
我们是否想在比较中包含花色可能是一个讨论点,因为一些纸牌游戏将一种花色视为比另一种更有价值。更重要的是,我们期望大于或等于( operator >=) )返回相反的结果。然而
Card{FaceValue(2), Hearts} >= Card{FaceValue(3), Hearts}
无法编译。如果我们编写一个小于运算符,其他比较运算符不会为我们生成。我们可以自己编写所有的比较运算符,但这既繁琐又容易出错。C++20 引入了 operator<=> ,有时被称为太空船运算符,因为它看起来有点像太空船,以便让我们的生活更轻松。太空船运算符返回三种可能值之一,因此也被称为三路比较运算符:
- x <=> y < 0 如果 x < y
- x <=> y > 0 如果 x > y
- x <=> y == 0 如果 x 等于 y
返回类型是一个顺序类别类型。涉及的详细信息很多,但对于整型,例如 int 或我们的 Suit 枚举,我们会得到一个 std::strong_ordering ,在 compare 头文件中定义(见 http://mng.bz/p1D0)。我们可以使用关键字 auto ,而不是查找我们需要使用的具体返回类型。这个结果可以自动转换为六个双向比较运算符之一。现在,我们可以自己实现太空船运算符,但我们也可以用关键字 default. 标记它。如果我们这样做,编译器会为我们生成所有的比较。默认比较运算符将使用类中定义的字段顺序,因此值和花色都会被比较。因此,字段需要是可比较的,所以我们在我们的 FaceValue 中也需要一个太空船运算符。默认版本将能够使用 value_ 成员比较两个 FaceValues 的值,这正是我们所需要的。
我们需要首先添加 compare 头文件,它可以计算返回类型并为我们合成比较运算符。然后,我们在 FaceValue 和 Card 的定义中各添加一行,最后得到了我们所需的内容。
列表 5.21 默认三路比较运算符
#include
namespace cards
{
...
class FaceValue
{
public:
...
auto operator<=>(const FaceValue&) const = default; ?
private:
int value_; ?
};
class Card
{
public:
....
auto operator<=>(const Card&) = default; ?
private:
FaceValue value_{1}; ?
Suit suit_{}; ?
};
};
生成默认比较
? 用于比较的值
生成默认比较
? 用于比较的值和套件
这花费很少的精力就为我们的两种类型添加了六个比较运算符。由于我们将 1 视为王牌,这个默认运算符意味着王牌是最低的牌。我们也可以编写自己的比较,或者使用值 2 到 14,使 14 成为王牌,因此是最高值的牌。请随意这样做以获得额外的练习。手握一副牌和比较牌的方式,我们现在可以创建一个高低牌游戏。
5.2 高低牌游戏
当我们创建我们的牌组时,它们是按顺序排列的,这样我们就可以推测接下来会发生什么。随机化顺序会使游戏更有趣,因此我们需要一种洗牌的方法。
5.2.1 洗牌
我们之前使用过随机数;然而,我们现在想要的是随机洗牌,而不是一系列随机数。 algorithm 头文件包含我们需要的方法。如果我们查看 CppReference (http://mng.bz/eEjZ),我们会看到 random_shuffle 和 shuffle 方法。每个 random_shuffle 版本都已被弃用或移除。一个版本使用了 C 的 rand 函数,这在某个时候可能会被弃用。我们已经看到 C++ 随机数生成器的表现要好得多。使用 rand 可能依赖于全局状态,这会给多线程代码带来问题。一些简单的 random_shuffle 实现也使用了 rand() % i 作为索引 i 来交换元素。每当我们对随机数使用模运算时,我们就有可能扭曲分布。Stephan Lavavej 在 2013 年做了一次题为“rand() 被认为是有害的”(见 http://mng.bz/g7Dn)的演讲,解释了为什么我们应该避免将 rand 与 % 一起使用。如果我们想模拟掷骰子,使用 rand() % 6 将不会给我们一个均匀的分布,因为 MAX_INT 不是六的倍数。因此,较低的骰子点数会稍微更有可能。试试看。
避免使用已弃用的洗牌方法,我们得到了 std::shuffle 。这需要物品进行洗牌和一个随机数生成器。我们可以将 begin 和 end 传递给 std::shuffle ,或者直接在我们的牌组上使用范围变体 std::ranges::shuffle 。我们将使用 random_device 来初始化一个 mt19937 生成器,就像我们之前做的那样。我们需要包含 algorithm 和 random 头文件,以便分别用于 shuffle 和随机生成器。我们需要通过引用传递牌组,以便我们可以更改它。
清单 5.22 洗牌
#include
#include
void shuffle_deck(std::array & deck) ?
{
std::random_device rd;
std::mt19937 gen{ rd() }; ?
std::ranges::shuffle(deck, gen); ?
}
通过引用传递甲板
? 为随机数生成器播种
洗牌
对于一个需要多次洗牌的纸牌游戏,创建一个具有洗牌方法的类是明智的,在构造函数中设置生成器。然而,列表 5.22 中的简单方法对于我们的高低牌游戏来说已经足够。我们现在有了一种洗牌的方法,因此可以构建我们的游戏。
5.2.2 构建游戏
我们将展示牌堆中的第一张牌,并询问玩家下一张牌是更高还是更低,我们将继续进行,直到牌用完或玩家答错。我们可以使用单个字符, 'h' 表示更高, 'l' 表示更低,这样玩家就不需要输入太多内容:
char c;
std::cin >> c;
我们比较当前卡片和下一张卡片,依赖于在列表 5.21 中的三路比较默认生成的 operator< 和 operator> 来查看猜测是否正确。
清单 5.23 检查猜测是否正确
bool is_guess_correct(char guess, const Card & current, const Card & next)
{
return (guess == 'h' && next > current)
|| (guess == 'l' && next < current);
}
游戏从牌堆中的第一张牌开始。我们可以通过多种方式找到数组中的第一张牌,但记录正确猜测的次数并在游戏结束时报告这一点可能会很好。我们可以使用这个计数来索引数组,就像使用 C 风格数组一样,索引将告诉我们在牌堆中走了多远。我们将遍历牌堆中的所有牌,但如果做出错误的猜测则停止。将这些结合在一起,我们得到了高低牌游戏的功能。
清单 5.24 高低牌游戏
void higher_lower()
{
auto deck = create_deck();
shuffle_deck(deck);
size_t index = 0;
while (index + 1 < deck.size()) ?
{
std::cout << deck[index] ?
<< ": Next card higher (h) or lower (l)?\n>";
char c;
std::cin >> c; ?
bool ok = guess_correct(c, deck[index], deck[index + 1]); ?
if (!ok)
{
std::cout << "Next card was " << deck[index + 1] << '\n';
break; ?
}
++index;
}
std::cout << "You got " << index << " correct\n"; ?
}
? 循环剩余的 51 张牌
? 显示当前卡片
? 更高或更低
? 检查猜测
? 如果错误则退出循环
? 显示有多少是正确的
我们将在扑克牌源文件中再次定义它,并在我们的头文件中声明它。然后我们从 main 调用它。
清单 5.25 我们的游戏
#include "playing_cards.h"
int main()
{
cards::higher_lower();
}
别忘了,王牌是最低的点数,花色也有顺序。正确的组合超过五个是很困难的。一个典型的游戏可能会这样进行:
9 of Spades: Next card higher (h) or lower (l)?
>l
4 of Hearts: Next card higher (h) or lower (l)?
>h
Next card was Ace of Hearts
You got 1 correct
我们有一个可工作的卡牌游戏。我们创建了一个简单的结构并将其用于数组中。我们让 C++为我们完成大部分工作,生成我们需要的比较,以决定一张牌是高还是低。我们可以在这里停止,但有些卡牌游戏也使用王牌。王牌没有花色或点数,那么我们如何将王牌添加到我们的牌组中呢?
5.2.3 使用 std::variant 支持牌或鬼牌
定义小丑的最简单方法是将其视为空结构体。
清单 5.26 一个小丑
struct Joker
{
};
这就是我们所需要的一切。
我们知道如何制作一副 52 张的扑克牌:
std::array cards = create_deck();
我们如何添加两个小丑?我们不能将小丑添加到这副牌中,因为它们是不同类型的。我们可以创建一个公共基类型并使用指针进行动态多态,但这似乎有些过于复杂。一个更简单的方法是使用一个包含两种类型的数组:牌或小丑。 std::variant ,在 C++17 中引入,使这成为可能。它位于 variant 头文件中,表现得像一个 union ,但更安全。C 语言的 union 类型有一系列可能的成员。
列表 5.27 一个联合体
union CardOrJoker
{
Card card;
Joker joker;
};
该联合足够大,可以容纳使用的最大类型。要从这个 union 访问一个 Card ,您使用 card 成员,对于一个 Joker ,使用 joker 成员,但您需要跟踪正在使用的类型。相比之下, variant 知道它当前持有的类型,因此 variant 通常被描述为类型安全的联合。
我们通过声明它可以容纳哪些类型来定义一个 variant :
std::variant
variant 是一个定义为可变参数模板的类模板。我们将在最后一章中更详细地讨论这些内容,但现在请注意定义中的三个点:
template
class variant;
这些点称为参数包,允许我们使用零个或多个模板参数。这使我们能够定义一个具有我们需要的两种类型的变体。我们在第三章中使用了 std::optional 来处理输入,这只需要一种类型。声明一个 optional 而不赋值
std::optional card;
没有价值。如果我们在布尔上下文中使用这张卡,它将评估为假,因此我们可以让 optional 起作用,但代码可能难以理解。我们需要记住 if(!card) 意味着我们有一张王牌。那么我们该如何使用 variant 呢?
一个 variant 被初始化为替代类型中的第一个,前提是该类型可以进行默认构造。如果不能,我们将得到一个编译错误。我们的两种类型都可以进行默认构造,因此在这里不会发生这种情况。所以使用
std::variant card;
给我们一个默认构造的 Card ,因为这是第一个类型。我们也可以创建一个 Joker :
std::variant joker{ Joker{} };
实际上,有多种方法可以创建变体。我们可以避免使用 std::in_place_index 函数构造临时 Joker{} 来构建变体。对于 Joker ,我们希望索引为 1,并且没有任何参数用于小丑的构造函数,因此我们将使用 std::in_place_index ,值为 1:
std::variant joker2(std::in_place_index<1>);
对于一个 Card ,我们使用零索引并将值和花色传递给 Card 构造函数:
std::variant two_of_clubs(std::in_place_index<0>,
FaceValue(2), Suit::Clubs);
有关更多详细信息,请参见 http://mng.bz/amzY。
我们可以通过检查变体的类型来确定是否有小丑
bool is_joker = std::holds_alternative(two_of_clubs);
有多种方法可以检索值。例如,我们可以使用 get 和索引:
Card from_variant = std::get<0>(two_of_clubs);
如果我们尝试获取一个 Joker 的话
Joker from_variant = std::get<1>(two_of_clubs);
一个 std::bad_variant_access 被抛出。或者,我们可以使用 get_if 来避免异常。我们可以使用一个类型 std::get
https://en.cppreference.com/w/cpp/utility/variant),但我们现在知道足够的信息来制作一副带小丑的扑克牌。
我们使用了 optional 并遇到了 variant 。还有第三种类型,称为 std::any ,它位于 any 头文件中。这三种类型在 C++17 中引入,提供了对类似问题的略微不同的替代方案。顾名思义,我们可以将 any 用于几乎任何东西,特别是任何可复制构造的类型。 any 变量可以根据需要切换到其他类型:
std::any some_card = Joker();
some_card = Card{ 2, Suit::Club };
我们需要使用 any_cast 方法来获取值。如果我们有 Card 而不是 Joker ,调用
std::any_cast(some_card);
会抛出一个 std::bad_any_cast 。
因此我们可以使用 any ;然而,使用 variant 更清晰,因为我们将会有一个 Card 或一个 Joker 。我们甚至可以使用 optional ,使用一个没有值的变量来表示一个 Joker ,但当我们使用 variant 时,意图更清晰。
5.2.4 使用扩展卡组构建游戏
让我们制作一个扩展的牌组。首先,我们需要在牌组中添加王牌。我们可以通过多种方式做到这一点。我们遇到了 array 并注意到我们可以使用聚合初始化来初始化一些或所有元素。因此,我们可以像这样制作前两个元素 Joker :
std::array, 54> deck{ Joker{} , Joker{} };
我们也可以像以前一样制作常规的 52 张牌:
std::array cards = create_deck();
如果我们复制这 52 张牌,我们将拥有一副包含两个小丑的牌。我们在第二章中曾使用过 copy 。有几种复制的变体,它们都位于 algorithm 头部。在第二章中,我们遇到了 ranges::copy 版本。我们在牌堆的开始有两个小丑,因此我们想在两个小丑之后复制牌。因此,我们需要从 begin + 2 开始复制,如图 5.2 所示。
在代码中,我们写道
std::ranges::copy(cards, deck.begin() + 2);
我们可以改用 std::copy ,使用 begin 和 end 成员函数:
std::copy(cards.begin(), cards.end(),deck.begin() + 2);
我们甚至可以使用 begin 和 end 免费函数:
std::copy(std::begin(cards), std::end(cards), std::begin(new_deck)+2);
有些东西,比如 C 风格数组,可以被迭代,但没有 begin 或 end 方法,在这种情况下可以使用这些自由函数。如果我们在有成员函数的情况下使用自由函数,它们会为我们调用成员函数,因此在这种情况下对我们没有任何区别。
我们需要在我们的头文件中包含 variant 头文件并声明该函数。使用 ranges 版本,我们可以在扑克牌源文件中创建一个扩展的牌组。
清单 5.28 创建扩展甲板
std::array, 54> create_extended_deck()
{
std::array, 54> deck{Joker{}, Joker{}}; ?
std::array cards = create_deck();
std::ranges::copy(cards, deck.begin() + 2); ?
return deck;
}
? 从两个小丑开始
? 在两个小丑之后复制一副普通牌
我们需要洗牌扩展的卡片组。我们原来的函数适用于 52 张卡片的数组。现在我们有一个变体数组,包含 Joker 或一张卡片,因此我们可以在头文件中声明一个重载函数:
void shuffle_deck(std::array, 54>& deck);
我们可以定义新的函数。
列表 5.29 洗牌扩展牌组
void shuffle_deck(std::array, 54>& deck)
{
std::random_device rd;
std::mt19937 gen{ rd() };
std::ranges::shuffle(deck, gen);
}
此洗牌与列表 5.22 中的前一个版本之间唯一的区别是牌组的类型。我们可以改写一个函数模板以节省重复。试试看!
我们需要两个补充,以使我们的高低牌游戏与扩展牌组一起工作。首先,我们需要决定涉及 Joker 的猜测是否正确。如果我们说如果任一张牌是小丑,则猜测是正确的,那么玩家实际上获得了一次免费回合。我们将使用 std::holds_alternative
清单 5.30 检查猜测是否正确的扩展牌组
bool is_guess_correct(char c,
const std::variant& current,
const std::variant& next)
{
if (std::holds_alternative(current) ||
std::holds_alternative(next))
return true; ?
Card current_card = std::get(current); ?
Card next_card = std::get(next); ?
return is_guess_correct(c, current_card, next_card); ?
}
如果任一张牌是小丑,则返回真
从变体中获取卡片
? 否则调用原始函数
我们可能需要显示小丑,因此我们需要为我们的变体重载流插入运算符。同样,我们使用 holds_alternative 来检查是否有小丑,在这种情况下,我们将 "JOKER" 发送到流;否则,我们调用我们的原始函数。
清单 5.31 流式输出卡片和小丑
std::ostream& operator<<(std::ostream& os, const std::variant& card)
{
if (std::holds_alternative(card)) ?
os << "JOKER";
else
os << std::get(card); ?
return os;
}
小丑
? 流式传输卡片
我们现在可以使用扩展牌组编写一个新游戏。代码与我们在清单 5.24 中的原始游戏相同,唯一不同的是扩展牌组的创建。
清单 5.32 带小丑的高低牌游戏
void higher_lower_with_jokers()
{
auto deck = create_extended_deck(); ?
shuffle_deck(deck);
size_t index = 0;
while (index + 1 < deck.size())
{
std::cout << deck[index]
<< ": Next card higher (h) or lower (l)?\n>";
char c;
std::cin >> c;
bool ok = is_guess_correct(c, deck[index], deck[index + 1]);
if (!ok)
{
std::cout << "Next card was " << deck[index + 1] << '\n';
break;
}
++index;
}
std::cout << "You got " << index << " correct\n";
}
? 创建一个带有王牌的牌组
我们相对不太可能得到一张小丑牌,但这也有可能发生。一个典型的游戏可能是这样的:
8 of Hearts: Next card higher (h) or lower (l)?
>l
3 of Hearts: Next card higher (h) or lower (l)?
>h
5 of Hearts: Next card higher (h) or lower (l)?
>h
5 of Diamonds: Next card higher (h) or lower (l)?
>h
Next card was Ace of Clubs
You got 3 correct
我们已经构建了我们自己的类型和更多。然而,我们还没有尝试面向对象编程。在下一章中,我们将编写另一个类并提供虚函数,以便更深入地了解类。
摘要
- 头文件需要包含保护, pragma 指令 once 现在得到了广泛支持。
- Use a scoped enum in preference to a C-style enum.
- 某些功能可以标记为默认或已删除。
- string 头部为数值提供了 to_string 方法。
- 在编译时已知大小时,使用 std::array 作为容器。
- 三路比较 ( operator <=> ) 在 C++20 中引入,可以标记为 default ,为我们生成比较。
- 使用 std::shuffle 来打乱一个集合,传递一个适当种子的随机数生成器。
- 如果一个对象是有限数量的不相关类型之一,请使用 std::variant 。
- 使用 std::any 如果您需要任何可能的可复制构造类型之一。
- 许多容器具有 begin 和 end 成员函数,但这些函数也可以作为自由函数使用,以便更广泛的应用。