专业编程基础技术教程

网站首页 > 基础教程 正文

通过例子学习现代C++ :3 字符串和数字的输入

ccvgpt 2025-01-15 11:14:40 基础教程 1 ℃

本章内容包括

  • 输入数字和字符串
  • 当我们可能没有值时使用 optional
  • 处理随机数
  • 进一步练习使用 lambda 表达式和 std::function

在本章中,我们将编写一个数字猜谜游戏,以练习如何通过字符串和数字获取输入。我们需要生成一个随机数供玩家猜测,接受玩家的输入,并报告猜测是否正确。我们会确保玩家的猜测是一个数字,因此我们将学习如何处理字符串和数字。如果猜测错误,我们会提供线索,首先提示“太大”或“太小”,然后再给出更多线索,比如有多少位是正确的。对随机数的简要介绍将为后续章节奠定基础,同时我们也会在这个过程中学习更多 C++的特性。

通过例子学习现代C++ :3 字符串和数字的输入

3.1 猜一个预定的数字

我们将从一个固定的数字开始进行猜测。猜一个永远不变的数字并不是一个有趣的游戏,但这让我们可以专注于处理用户输入。如果我们把这个预设的数字放在一个函数里,我们可以在之后进行更改。

列表 3.1 一个需要猜测的数字

unsigned some_const_number()
{
    return 42;
}

随意选择另一个数字。我们不需要为此编写完整的函数,但这样做可能会让猜数字游戏的代码比使用硬编码或魔法数字更清晰。稍后我们会将其替换为随机数字。现在,我们只需获取用户输入并检查它是否匹配。

3.1.1 以复杂方式接受用户输入

在上一章中,我们使用流插入 operator << 将值发送到屏幕。 iostream 头部同样通过流提取 operator >> 提供输入。我们可以使用这个操作符将输入发送到变量,方法如下:

unsigned number;
std::cin >> number;

它适用于所有标准的 C++ 类型,就像 operator<< 一样。我们试图将任何输入流入 unsigned ,因为我们要猜的数字是 unsigned 。如果用户输入数字并按下 Enter,变量可能会包含一个数字。在上一章中,我们看到可以将负数赋值给 unsigned 。对于有符号数字,高位比特表示数字的符号,而无符号数字则将此比特作为值的一部分。因此,我们可以说 unsigned int number = -2 ,这段代码将编译,但在 Visual Studio 2022 中,数字将具有一个很大的正值 4294967294 。此外,输入可能不是数字,或者可能太大而无法适应我们选择的数值类型。 这表明直接流入一个 unsigned 并不是个好主意,但我们可以通过一些额外的努力让它表现得相对不错。我们将在下一节尝试其他方法。

让我们看看如果我们直接输入到一个 unsigned ,能走多远。操作符会跳过任何初始空格,然后读取字符,直到按下回车键,如图 3.1 所示。如果只有初始空格和几个数字,一切都正常。空格会被忽略,数字会被转换为存储在 unsigned 变量中的值。然而,如果输入不适合 unsigned ,会发生两件事:输入流会进入错误状态,并且有未使用的字符需要清理。

一旦遇到不合适的字符,就会设置一个标志,我们可以通过直接调用 std::cin .fail() 来检查。我们还可以通过检查 (std::cin >> number) 是否为真,来将运算符显式转换为 bool 。流的转换是通过 explicit operator bool 进行的,这意味着它可以被显式转换为 bool 。CppReference (http://mng.bz/W164) 将此检查称为惯用法。运算符被标记为 explicit ,因此我们需要处于一个期望 bool 的上下文中,例如 if 或 while ,这意味着我们不能意外地将流转换为 bool 。如果出现错误,我们需要清除失败标志,并使用 ignore 函数来处理坏字符。 这个函数接受两个参数:要提取的字符数量和一个分隔符,用于停止提取,因此我们希望提取尽可能多的字符,并在换行符 '\n' 处停止。接下来,我们可以循环,直到用户输入合理的内容。将这些内容整合在一起,并包括 limits 和 iostream 的标题,得到以下结果。

示例 3.2 从标准输入读取数字

unsigned input()
{
    unsigned number;
    while (!(std::cin >> number))                                  ?
    {
        std::cin.clear();                                          ?
        std::cin.ignore(
            std::numeric_limits<std::streamsize>::max(), '\n');    ?
        std::cout << "Please enter a number.\n>";
    }
    return number;                                                 ?
}

? 流式传输字符并检查是否有失败情况

清除故障标志

处理无效输入

如果我们跳出循环,将返回一个数字

根据我们在列表 3.1 中预先设定的数字,我们可以利用列表 3.2 中的输入函数来创建一个猜谜游戏。

列表 3.3 第一次尝试数字猜谜游戏

void guess_number(unsigned number)
{
    std::cout << "Guess the number.\n>";
    unsigned guess = input();
    while (guess != number)                 ?
    {
        std::cout << guess << " is wrong. Try again\n>";
        guess = input();
    }
    std::cout << "Well done.\n";            ?
}
int main() 
{
    guess_ number(some_const_number());     ?
}

猜测错误时进行循环

? 只有在正确猜测时才会退出循环

? 使用我们预设的数字调用猜测函数

我们可以玩这个游戏,但也可以进行一些改进。我们会确保输入一个数字。如果我们输入一些无意义的内容,就会一次又一次地被提醒,直到我们输入一个数字,如图 3.2 所示。

现在试试一个负数,比如 -1 。图 3.3 展示了会发生什么。

我们知道为什么会发生这种情况;当我们将 -1 赋值给 unsigned 时,它会出现循环。我们可以通过将类型更改为 int 来解决这个问题。如图 3.4 所示,如果我们尝试其他一些非负数,并以我们不太随机的数字 42 结束,我们就赢了。


只要我们避免错误的输入,就能进行一个相对可预测的游戏。

我们有一个类似于数字猜谜游戏的东西,但如果能给用户一个表示放弃的方式就更好了。通过更改输入函数,我们可以让数字输入变为可选,这样用户就能更轻松地结束游戏。

接受可选的数字输入选项

在 cin 中的 c 表示字符。我们可以将字符流直接输入到字符串中,而不是直接输入到数字类型。如果我们包含 string 头部,就可以以这种方式接受输入:

std::string in;
std::cin >> in;

string 将包含用户输入,但 cin 会在空格处停止。如果我们输入“Hello, World!”,字符串只会包含“Hello”,而将剩余的输入留给另一个 string 或者忽略。我们也可以这样获取整行:

std::string in;
std::getline(std::cin, in);

这将收集每个字符,包括空格,直到行的末尾,留下行末之前的字符供我们在 std::string in 中使用。然后我们可以选择如何处理整行内容。

因为我们想将输入与一个数字进行比较,所以需要对输入进行转换。如果我们编写一个合适的函数,称为 read_number ,并接受一个流,我们将处理从 getline 获得的字符串,并在其前面加上 sstream 头部:

std::istringstream in_stream(in);
auto number = read_number(in_stream);

我们如何实现这个 read_number 函数?有多种方法可以尝试从字符串或流中解析整数。处理 IOStreams 可能会迅速变得非常复杂。安吉丽卡·兰格和克劳斯·克雷夫特合著了一本名为《标准 C++ IOStreams 和区域:高级程序员指南与参考》(阿迪森-韦斯利专业出版社;2000)的书,提供了深入的内容。这本书非常厚重,反映了这一主题的复杂性。为了简化问题,我们将在这里使用 std::optional ,这将使我们的工作变得更加轻松。

optional 类型是在 C++17 中引入的,位于 optional 头文件中。它有时被称为词汇类型,和 std::any 以及 std::variant 一起使用。它们是模板,因此可以将类型作为参数。根据我们在图 3.3 中看到的结果,我们知道应该使用整数而不是无符号整数,因此我们将使用有符号整数作为模板类型:

std::optional<int> value;

这没有值。我们可以通过显式检查 has_value() 成员函数或使用 explicit operator bool 来判断 optional 是否有值;换句话说,就是在 if 或 while 表达式中使用 optional 或类似的方式。这与之前使用的流有着相似的语义。值得注意的是 C++ 语言和库中的一些模式。它们可以通过展示合理的方法来指导我们的代码。没有值可能是合理的,但我们可以用整数来初始化这个值。

std::optional<int> value = 101;
 

或者修改该值:

value = -2;

这使得 optional 可以包含一个值。一些函数式编程语言有 maybe 类型的概念。如果我们使用可选类型,就不需要保留值来表示变量未设置。 operator bool 将在值被设置时返回 true 。如果我们想使用这个值,可以调用 value 函数:

int actual_value = value.value();

如果 optional 没有值,我们会遇到异常;如果有值,我们将得到一个数字。

现在我们可以编写一个函数,从流中读取一个数字。我们可以在函数外部使用 getline 来创建一个流,读取整行输入,或者在我们的 read_number 函数中处理非数字输入。如果选择后者,当我们调用函数时,就不需要记得这样做。我们的新函数如下所示。

列表 3.4 进行可选输入操作

std::optional<int> read_number(std::istream& in)
{
    int result{};                                                   ?
    if (in >> result) {                                             ?
        return result;                                              ?
    }
    in.clear();                                                     ?
    in.ignore(std::numeric_limits<std::streamsize>::max(), '\n');   ?
    return {};                                                      ?
}  

? 将整数初始化为零。

尝试读取数字

? 返回一个整数(可选)

清理整理

否则返回一个空的选项

请注意,我们在倒数第二行返回一个空的 optional 。如果返回 result ,那么就会返回一个 int ,这会使得 optional 有一个值,从而违背了使用 optional 来表示用户希望停止猜测的初衷。

我们通过将输入流传递给读取函数,而不是将其固定为标准输入,给自己提供了更多选择。例如,我们可以在函数外部使用 std::stringstream in_stream(in) 获取整行输入,然后将其传入。这意味着我们仍然能够知道用户输入了什么。如果直接发送 cin ,我们决定在流中没有数字时清除它,这样我们就失去了输入。这对于我们的游戏来说已经足够,但我们可以看到我们在这里有不同的选择。

如果用户输入一个数字,我们的新函数将返回一个有值的 optional ;否则,将返回一个空的 optional 。我们可以在 while 循环中检查是否有空的可选项。

while (guess = read_number(std::cin))    

这样我们可以退出循环,如果玩家没有输入数字,就停止请求猜测。请注意,一些编译器在将赋值结果用作条件时可能会发出警告,尤其是在使用带有警告标志 -Wparentheses 的 clang 或 GCC 时。使用第二组括号表明我们确实打算检查赋值的结果,从而避免警告:

while ((guess = read_number(std::cin)))    

如果玩家放弃,我们甚至可以告诉他们这个数字是什么。综合来看,我们有代码可以让游戏变得稍微更好一些。

列表 3.5 允许放弃的情况

void guess_number_or_give_up(int number)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))                ?
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;                                      ?
        }
        std::cout << guess.value() << " is wrong. Try again\n>";
    }
    std::cout << "The number was " << number << "\n";    ?
}
 
int main()
{
    guess_number_or_give_up(some_const_number());
}

如果输入不是数字,则跳出循环

猜对了就停止

告诉玩家这个数字

如果我们现在开始游戏,可以通过输入“放弃”或任何非数字的内容来选择放弃(见图 3.5)。

我们的游戏运行正常,但如果能在玩家出错时给他们一些提示就更好了。一旦我们实现了这一点,就可以开始使用随机数字了。

3.1.3 使用 std::function 和 lambda 表达式进行验证与反馈

如果猜测错误,那么要么是太大,要么是太小。我们可以在这里进行检查,但使用验证函数会给我们更多的灵活性。虽然在这里我们只会报告数字是太大还是太小,但在我们创建质数猜测游戏的最后部分时,我们会添加各种其他反馈。我们将再次使用一个 lambda,并看看如何将其传递给我们的猜测游戏。

我们希望将函数签名修改为类似于以下内容:

void guess_number_or_give_up(int number, lambda message)

然而,并没有 lambda 关键字。每个 lambda 都有其独特的类型,因此我们需要另一种方式来表示我们有可以调用的对象,比如函数或 lambda,这被称为可调用对象,作为我们的第二个参数。我们可以使用模板:

template<typename T>
void guess_number_or_give_up(int number, T message)

然而,这并不意味着该消息是可调用的。我们可以使用一个概念来限制模板类型,提供一种替代方法,我们将在下一章中探讨。目前,我们将使用 std::function 。这将帮助我们更好地理解 lambda 表达式。

std::function 是一个模板,为 lambda、命名函数或任何可调用对象提供通用的封装。我们需要在模板中指定返回值和参数类型。对于我们的游戏,我们有一个数字和一个猜测,这些是我们消息函数的输入,我们希望返回一个可以显示的消息,这可以是一个 string 。对于命名函数,签名将如下所示:

std::string message(int, int);

返回类型首先出现,接着是函数名称和参数(在我们的例子中是两个 int )。要创建一个 std::function ,我们需要包含 functional 头文件,并声明一个具有相同签名的函数包装器:

std::function<std::string(int, int)> callable;

模板参数 std::string(int, int) 看起来像一个命名函数,但没有名称。我们可以像调用任何函数一样调用 callable :

auto message = callable(1, 2); 

因为我们没有指定 callable 应该执行什么,所以它是一个空函数,因此会抛出异常。这与 optional 的行为相似。我们可以用一个 lambda 表达式来初始化 callable :

std::function<std::string(int, int)> callable = [](int number, int guess) { 
    return std::format("Your guess was too {}\n",
        (guess < number ? "small" : "big")); 
};

这个函数现在不再是空的,我们可以安全地调用它。请注意,我们再次使用了 std::format 。第 2.2.5 节提供了如何使用 fmt 库的说明,如果你的编译器尚不支持 std::format 。别忘了,你需要将 std::format 更改为 fmt::format ,并包含 fmt/core.h 头文件,而不是标准的 format 头文件。我们现在可以为游戏添加一个额外的参数,用于消息功能,这样在玩家猜错时我们可以给他们提供线索。

列表 3.6 提供错误猜测时的线索

void guess_number_with_clues(unsigned number, 
        std::function<std::string(int, int)> message)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
             std::cout << "Well done.";
             return;
        }
        std::cout << message(number, guess.value());       ?
        std::cout << '>';                                  ?
    }
    std::cout << std::format("The number was {}\n", number);
}

猜错时会显示一条消息

? 在消息后添加提示信息

我们还需要修改我们的 main 函数,通过一个 lambda 函数来传递消息。我们可以直接发送消息,或者在单独的一行中使用 auto 来声明 lambda。

列表 3.7 改进版数字猜谜游戏

int main()
{
    auto make_message = [](int number, int guess) { 
        return std::format("Your guess was too {}\n",
            (guess < number ? "small" : "big")); 
    };
    guess_number_with_clues(some_const_number(), make_message);
}

我们为什么将 message 声明为 auto 而不是指定 std::function<std:: string(int, int)> 呢?虽然这样可以减少输入量,但这里还有一个重要的点需要注意。lambda 或闭包的类型是我们无法命名的,但 auto 可以为我们推断出确切的类型。两个接受相同参数并返回相同类型的 lambda 实际上是不同的类型。然而,这两个 lambda 可以被赋值给同一个 std::function 。这对我们的目的很有帮助,但也有一些缺点。lambda 可以内联,从而避免函数调用的开销。如果我们将一个 lambda 复制到 std::function 中,它就无法再内联,因此调用它可能会变得更慢。将我们的 lambda 复制到 std::function 中也可能涉及动态内存分配。 斯科特·迈耶斯在他的书《有效的现代 C++》(O'Reilly Media,2014)中的“项目 5:更倾向于使用自动类型声明而非显式类型声明”中详细说明了这一点,我们已经知道几乎总是应该使用 auto 。如果我们将 lambda 声明为 auto ,就可以避免开销,尽管它会在方法调用中被复制到 std::function 。实际上,我们可以在列表 3 中更改函数签名。也可以使用 auto :

void guess_number_with_clues(unsigned number, auto message);

我们现在有了另一个几乎总是使用 auto 的理由。然而,我们已经不再把消息生成器视为一个可调用的函数了。一旦我们对这些概念有了一定的了解,就可以解决这个问题。对于那些不耐烦的人,我们可以直接包含概念的标题并说明。

void guess_number_with_clues(unsigned number,
    std::invocable<int, int> auto message)

如果我们传递一些无法用两个整数调用的内容,就会得到有用的编译器错误。我们将在下一章中看到更多概念。对于患者,提出了一项引入 std::function_ref 作为 std::function 替代方案的建议,以克服性能问题(http://mng.bz/wjgg)。C++正在不断发展,以便让我们的生活更轻松。无论我们如何发送消息,当我们尝试猜测数字时,现在都会得到一些线索(图 3.6)。



我们现在有一个运作正常的数字猜谜游戏,虽然有些无聊。我们可以通过选择一个随机数字来让它更有趣。

猜测一个随机数

C++11 引入了一个随机数库。虽然使用它比 C 的 rand 函数需要更多的努力,但它提供了多种生成具有不同有用属性的随机数的方法。本节将展示如何从众多分布中获取随机数。我们需要选择一个种子和一个引擎,并决定使用哪种分布。我们将在第六章中更详细地探讨这些分布。本节为后续内容奠定了基础。

3.2.1 随机数生成器的设置

对于我们的猜谜游戏,我们需要一个随机整数。从一个区间中选择随机数是个不错的主意,并且每个数字出现的概率应该是相同的,因此我们将使用称为 uniform_int_distribution 的均匀整数分布。这种分布适合模拟掷骰子,每次掷骰子都需要一个介于一到六之间的数字,且不偏向任何结果。它在任何需要同样可能的整数的情况下都很有用,比如在我们的游戏中选择一个数字让我们来猜。

每个分布都是一个模板,用于生成特定类型的数字。 uniform_int_distribution 被限制为整数类型。对于浮点数或双精度数,有一个类似的 uniform_real_distribution 。我们将使用一个整数,并请求在 1 和 100 之间的数字(包括这两个数字)。

std::uniform_int_distribution<int> dist(1, 100);

C rand 函数不支持区间,而我们在使用随机数时通常希望有区间。例如,掷骰子需要一个在 1 到 6 之间的数字,或者从一副牌中抽取一张牌需要一个在 1 到 52 之间的数字。C++ 在这方面为我们提供了便利,让我们能够明确指定。

为了生成数字,分布需要一个引擎或生成器。引擎提供随机数字。是的,为了生成随机数字,分布需要提供随机数字。分布使用概率函数来确保数字是均匀的,或者遵循所请求的任何分布。对于某个范围内的均匀数字,分布会将引擎提供的数字压缩或转换到所请求的区间。如果我们使用 C 语言的 rand ,那么我们就必须自己将数字压缩到该区间。

我们无法通过一个函数生成真正的随机数,因为一个每次调用时返回不同值的函数通常会被视为错误。那么,随机数引擎是如何工作的呢?我们可以通过编写一个以种子为起点并进行一些算术运算的函数来生成伪随机数,从而生成一个新数字,并在下次调用时记住这个新数字。最终,如果生成的数字与原始种子相同,数字将开始重复。许多伪随机数生成器使用多项式函数结合模运算。我们也可以自己编写一个生成器。

列表 3.8 一个糟糕的随机数生成器

int random_number(int seed = 0)
{
    static int x = 0;   ?
    if (seed)
        x = seed;
 
    x = ++x % 2;        ?
    return x;
}

静态存储用于保存下一个调用的数字

? 从最后一个值创建一个新值

这是一个糟糕的随机数生成器,因为它只会返回 0 或 1 ,而这两个值是交替出现的。我们可能会得到 0, 1, 0, 1, ... ,或者 1, 0, 1, 0, ... ,这取决于种子。由于它每两个数字就会重复一次,因此它的周期为 2。幸运的是,C++提供了几种性能更好的引擎,其中包括名为 mt19937 的引擎。 mt 代表梅森旋转器。这些生成器在模数部分使用的质数是比 2 的幂少 1 的质数,称为梅森质数,并且它们的计算步骤比我们的增量 ++x 要好得多。这个引擎的周期为 219937 - 1。我们也可以使用 std::default_random_engine ,这可能是 mt19937 引擎。

有多种方法可以为随机数引擎设置种子。如果我们使用一个特定的数字,每次运行都会得到相同的随机数序列。通过提供相同的种子来重新生成伪随机数序列的能力对于模拟和测试非常有用,因为每次运行的结果都是一致的。我们可以使用当前时间在每次运行时获得不同的数字,但我们还没有学习 C++中的时间。我们将在下一章中学习。 random 头提供了一个 random_device ,它本身就是一个随机数生成器,能够生成非确定性的随机数。CppReference 指出,它可能在每次调用时生成相同的数字序列(http://mng.bz/84RZ)。 一些较旧的实现总是返回 0 ,因此值得检查一下如果你多次调用它是否会得到不同的数字。随机设备可能会利用你的硬盘状态或类似的物理组件来生成一个数字。CppReference 也提醒我们,尽管它生成随机数,但它的设计是为了生成一个种子,因为重复调用可能会导致生成相同的数字。

在添加了 random 头文件后,我们使用随机设备为随机数生成器设置种子:

std::random_device rd;
std::mt19937 engine(rd());

这为我们提供了使用分配所需的引擎或发电机。

3.2.2 使用随机数生成器进行操作

有了种子和引擎,我们现在可以从分布中抽取一个数字。我们通过调用分布的 operator() 来完成这个过程。

列表 3.9 生成单个随机数

int some_random_number()
{
    std::random_device rd;                              ?
    std::mt19937 engine(rd());                          ?
    std::uniform_int_distribution<int> dist(1, 100);    ?
    return dist(engine);                                ?
}

? 一种生成随机数的设备

? 一台配备设备的引擎

? 一种选择数字的分布

我们的实际随机数

生成一个数字确实需要相当多的代码,但我们需要一个种子、一个引擎和一个分布,才能请求一个随机数。在这里,我们不能少写代码。我们在未来的章节中也会使用随机数,因此会有更多的练习。如果我们想要多个随机数,可以创建一个类,在构造函数中设置种子和分布,每次需要新数字时从成员函数调用 dist(engine) 。我们将在第 5 章创建一个类,这里只需要一个数字,所以这个函数正好满足我们的需求。

注意到 C++比 C 函数给了我们更多的控制。引擎可以切换到另一个重复次数更少的引擎,尽管这里的 mt19937 已经足够,因为我们只需要一个数字。我们还指定了随机数的范围。前三行的设置只需进行一次。如果我们想要另一个随机数,可以再次调用 dist(engine) 而无需重新设置。如果我们多次调用这个并记录结果,我们会发现 0 到 100 的数字大致以相等或均匀的比例生成。

现在我们可以通过在 main 函数中调用新函数来让游戏稍微更具挑战性,同时保持 some_const_number 不变,其他部分也不变。

列表 3.10 随机数字猜谜游戏

int main()
{
    auto message = [](int number, int guess) {
        return std::format("Your guess was too {}\n",
            (guess < number ? "small" : "big")); 
    };
    guess_number_with_clues(some_random_number(), message);    ?
}

可能不是 42 的改变

我们可以改变消息以提供不同的提示(例如,数字是奇数还是偶数,或者我们可以记录猜测,以提醒用户他们是否已经尝试过某个数字)。虽然我们在这里不这样做,但可以看到传递消息使代码保持相对灵活。我们将生成一个需要猜测的质数。因此,我们将学习如何生成具有特定属性的随机数,这里是质数,并在数字错误时提供提示。

猜测一个质数

为了更好地练习随机数字,我们将生成一个质数供玩家猜测。如果玩家猜错了,我们会告诉他们哪些数字是正确的。这也能让我们在消息的 lambda 表达式上多加练习。

3.3.1 检查一个数字是否是质数

如果我们想要生成一个质数,就需要调整生成要猜测数字的函数。与在列表 3.9 中立即返回 dist(engine) 不同,我们可以先检查这个数字是否为质数。如果是质数,我们就返回它;如果不是,我们会继续尝试其他随机数字,直到找到合适的。我们该如何检查一个数字是否为质数呢?

质数只有两个因子。1 只有一个因子,因此我们可以特殊处理这个情况并返回 false。2 可以表示为 1 × 2(或 2 × 1),所以它恰好有两个因子,这就是第一个质数。3 是下一个质数,因此我们可以立即对这两个数字返回 true。之后的任何 2 或 3 的倍数都不是质数。例如,6 可以被 2 和 3 整除,同时也可以被 1 和 6 整除。因此,我们可以使用 operator% 来进行这些检查。

数字 4 在检查 2 的倍数时被捕获。因此,我们只需检查数字是否是从 5 开始的任何数字的倍数,因为我们已经考虑了 2、3 和 4。我们可以跟踪找到的质数,而不仅仅是考虑 2 或 3 的倍数,并构建所谓的埃拉托斯特尼筛法。这种方法会更高效,但这意味着我们需要记录质数。为了节省时间,我们可以在数字的平方根处停止检查,超出这一点是没有意义的。例如,数字 35 可以表示为 5 × 7。从 5 开始检查时,我们立即找到了一个因子,因此可以确定 35 不是质数。我们在 35 的平方根之前就发现了这一点,稍微少于 6。 找到第一个因子后,我们不需要再检查 7,因为我们已经找到了 5 并返回了。如果一个因子大于平方根,总会有另一个因子小于平方根,而我们会先找到这个因子。我们将因子的检查整合到一个函数中,如下所示。

列表 3.11 用于检查一个数字是否为质数的函数

bool is_prime(int n)
{
    if (n == 2 || n == 3)                      ?
        return true;
 
    if (n <= 1 || n % 2 == 0 || n % 3 == 0)    ?
        return false;
 
    for (int i = 5; i * i <= n; ++i)           ?
    {
        if (n % i == 0)
            return false;                      ?
    }
 
    return true;                               ?
}

? 2 和 3 是素数。

? 1 以及任何 2 或 3 的倍数都不是质数。

检查 5 及以上是否为因数

我们找到了一个因子,因此这个数字不是质数。

如果我们能到这里,那就是一个素数。

我们可以进行其他优化来提高函数的速度,但这对于我们的游戏来说已经足够快了。我们有一种检查数字是否为质数的方法,但在使用之前,我们会为这个函数添加一些测试。

3.3.2 利用 static_assert 检查属性

我们将添加一个函数来测试我们的 is_prime 函数是否正常工作。我们可以为测试硬编码一些数字。这意味着我们不使用任何运行时输入,因此可以在编译时进行检查。我们通过在函数签名的开头添加关键字 constexpr 来表示这一点,constexpr 是常量表达式的缩写:

constexpr bool is_prime(int n)

说一个函数或变量是 constexpr 意味着理论上它可以在编译时被评估,但实际上可能并非如此。一个 constexpr 变量是 const ,这意味着我们无法改变它的值。对于 constexpr 函数,参数也必须是常量表达式。如果它们直到运行时才被设置,例如通过用户输入,那么在编译时就无法进行评估。因此, constexpr 表示一个值或返回值在可能的情况下是常量并且在编译时计算的。因此,使用 constexpr 可以让我们在编译时评估变量或函数。让我们来看看具体如何操作。

我们仍然可以在运行时调用我们的函数,但现在也可以在编译时检查代码。我们可以在测试函数中使用 static_assert ,而不是像上一章那样使用 C 的 assert 函数:

void check_properties()
{
    static_assert(is_prime(2)); 
}

static_assert 可以在其他地方使用,比如命名空间(见 http://mng.bz/E97o),但为我们的测试创建一个函数使其更容易找到。 static_assert 需要一个常量表达式,比如我们的 constexpr 函数,如果表达式为假,就会产生编译错误。我们可以在 main 函数的开头添加对 check_properties 函数的调用,这样我们的单个断言在编译时就能通过,运行时无需任何操作。如果我们使用一个非质数,比如 4,而不是 2,就会出现编译错误:

main.cpp(108,24): error C2607: static assertion failed

及早发现和捕捉错误总是一件好事。此外,编译时评估可以加快运行时的速度。 static_assert 和 constexpr 是在 C++11 中引入的,后者随着时间的推移变得更加灵活,允许使用局部变量和循环。在此之前,我们需要使用递归。C++20 随后引入了修饰符 consteval 和 constinit 。 consteval 用于函数,以确保它们在编译时被评估,而 constexpr 可能会或可能不会在编译时被评估。 constinit 用于变量,确保在编译时进行初始化。 consteval 函数也称为即时函数,如果无法在编译时评估,我们将会遇到编译错误。

我们还可以看到声明的变量 constexpr :

constexpr int x = 41 + 1;
constexpr bool x_prime = is_prime(42);

这使得变量在编译时既是常量又是计算得出的,因此我们无法更改它们。尝试通过 x = 43 来实现这一点会导致编译错误。编译时评估是一个强大的工具。现在需要注意的是, constexpr 函数可以在编译时或运行时执行。

现在我们知道如何判断一个数字是否为质数,我们可以利用这个方法生成一个质数,供我们在游戏中猜测。

生成一个随机的质数

我们看到如何在列表 3.9 中生成随机数。我们使用 random_device 为引擎和分布提供种子,从一个范围中选择随机数。在 1 到 100 之间的素数并不多,因此我们将范围扩大到 99,999,以获得更多可能的素数,最多可达五位数。我们需要检查生成的第一个数字是否满足我们的要求,而不是直接返回它。我们使用 is_prime 函数,不断尝试,直到在一个空的 while 循环中找到合适的数字。让我们使用 {} 来初始化所有内容,以提醒自己均匀初始化的重要性。

列表 3.12 生成素数

int some_prime_number()
{
    std::random_device rd;
    std::mt19937 engine{ rd() };
    std::uniform_int_distribution<int> dist{1, 99999};    ?
    int n{};                                              ?
    while (!is_prime(n))                                  ?
    {
        n = dist(engine);                                 ?
    }
    return n;
}

使用更长的时间间隔

? 默认通过 {} 对 n 进行初始化

? 持续进行,直到我们找到一个质数

过滤掉不符合标准的随机数称为拒绝采样。这是一种简单的方法,用于生成满足特定属性的随机数。许多分布能够提供适用于模拟和游戏的随机数,但当某个分布在数学上难以表达时,拒绝采样就显得非常有效。

我们现在可以对猜数字游戏进行修改,使用随机生成的质数,并相应地调整第 3.10 节中对该游戏的调用:

guess_number_with_clues(some_prime_number(), message);

这一切都很好,但我们可以提供更好的线索。我们可以通过一些思考来判断哪些数字是正确的。只有 10 个数字,因此我们可以用不同的数字进行两次猜测。如果新的线索告诉我们哪些数字在这个数字中,我们就知道该使用哪些数字。虽然我们可能会把它们放错位置,某些数字可能会重复,但猜测这个数字应该会容易得多。

确定哪些数字是正确的

我们将使用字符 ^ 表示位置错误的数字, * 表示位置正确的数字,点表示不存在的数字。如果数字是 12347 而我们猜测 23471,虽然我们猜测了所有数字,但它们的位置都不对。我们会通过显示“ ^^^^^ ”来表示这一点。如果数字是 78737 而我们猜测 87739,我们将显示“^^**”。将这个结果显示在猜测的下方将会得到

87739
^^**.

第二个 7 和 3 的位置正确,因此它们得到了一个 * 。开头的 7 和 8 位置错误,因此每个得到了一个^。最后的数字 9 是错误的,所以它得到了一个点。

为了创建线索,我们需要一个函数,该函数接受一个数字和一个猜测,并返回一个字符串。如果我们将数字转换为字符串,就可以逐个检查每个数字。有多种方法可以实现这一点,我们将使用 format 。我们希望添加前导零,使得数字本身和猜测都是五位数。在上一章中,我们使用了格式说明符 "{: ^6}" ,通过空格填充数字,确保它的长度为六个字符。 ^ 表示居中对齐。这次,我们希望右对齐,因此使用 >, ,并希望用 0 代替空格,得到 "{:0>5}" 。如果我们创建一个由五个点组成的字符串, std::string matches(5, '.') ,并在数字正确的地方放置星号,那么我们就完成了一部分。

列表 3.13 函数的开始部分,指明哪些数字是正确的

std::string check_which_digits_correct(int number, int guess)
{
    auto ns = std::format("{:0>5}", (number));            ?
    auto gs = std::format("{:0>5}", (guess));             ?
    std::string matches(5, '.');                          ?
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)
    {
        char guess_char = gs[i];
        if (i < ns.length() && guess_char == ns[i])
        {
            matches[i] = '*';                             ?
        }
    }
    return matches;
}

把数字转换成字符串

? 从五个点开始

? 用星号标记正确的数字

现在我们需要找出是否有数字放错了位置。如果数字是 78737,而我们猜测的是 87739,那么我们有两个 7。其中一个是正确的,因此它得到了一个 * ,而另一个是错误的。如果我们将数字中的中间 7 改为 * ,在检查放错位置的数字时就不会使用它。我们可以在第一次循环中完成这一步;然后在第二次循环中找到放错位置的数字,并用 ^ 表示。一旦我们将某个数字标记为放错位置,我们也会将其更改为 ^ ,以避免在数字中只有一个时报告两个放错位置的数字。例如,如果数字是 12347,而猜测是 11779,两个 7 都是错误的,但我们想表示我们有一个放错位置的 7,而不是两个。

11779
*.^..

如果两个 7 都得到了 ^ ,这表明它们的位置不正确,意味着这个数字中有两个 7。然而,我们的反馈明确指出这个数字中只有一个 7。

一个 std::string 有一个查找方法,如果没有匹配的位置,则返回 npos 。一些编译器现在也支持一个更简洁的 contains 函数,但我们需要位置,以便在找到数字后避免再次使用它,因此我们需要使用 find 。 find 函数接受一个要查找的字符和一个起始位置,并返回一个索引。因为我们想从开始位置进行搜索,所以需要使用起始位置 0 。如果返回 npos ,这意味着字符不存在。我们可以在一个 if 语句中完成这个操作,使用带初始化的 if 语句:

if (size_t idx = ns.find(guess_char, 0); idx != std::string::npos)

这是在 C++17 中引入的。它看起来像一个普通的 if ,但有一个初始化,后面跟着一个分号和一个条件: if (init; condition) 。如果没有这个,我们就必须先找到索引,然后在单独的语句中检查值。无论哪种方式都可以,但带有初始化的 if 语句可以使代码更加紧凑,特别是通过缩小变量的作用域,因为变量仅在 if 块内有效。将对错误数字的检查添加到之前的列表中,得到了以下内容。

列表 3.14 显示了错误的数字

std::string check_which_digits_correct(int number, int guess)
{
    auto ns = std::format("{:0>5}", (number));
    auto gs = std::format("{:0>5}", (guess));
    std::string matches(5, '.');
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)
    {
        char guess_char = gs[i];
        if (i < ns.length() && guess_char == ns[i])
        {
            matches[i] = '*';
            ns[i] = '*';                                     ?
        }
    }
    for (size_t i = 0, stop = gs.length(); i < stop; ++i)    ?
    {
        char guess_char = gs[i];
        if (i < ns.length() && matches[i] != '*')
        {
            if (size_t idx = ns.find(guess_char, 0);
                idx != std::string::npos)                    ?
            {
                matches[i] = '^';
                ns[idx] = '^';                               ?
            }                                                ?
        }
    }
    return matches;
} 

不要重复计算这个数字。

? 现在检查那些不匹配的猜测

? 查找猜测的字符

? 这个数字也不要重复使用。

? idx 现在已经不在有效范围内。

我们可以并且应该为我们的属性函数添加测试。例如,在添加了 cassert 头之后,我们可以进行一个检查:

assert(check_which_digits_correct(12347, 23471) == "^^^^^");

本书中提供的代码在属性函数中包含了多个测试,涉及重复和缺失的数字,出于简洁考虑,这里省略了相关内容。

我们现在可以使用我们的函数在猜谜游戏中创建一个线索,并从 main 调用我们的属性测试。在进行更改时,我们将返回格式化为五位数字的结果。这样,较短的数字会有前导零,使得 ^ 看起来像是指向任何错误的数字。例如,如果数字是 17231,而我们猜测 1723,我们会看到

01723
.^^^^

这不是必需的,但它会提醒玩家可以使用零。下面的列表展示了我们在整合这些内容时所得到的结果。

列表 3.15 一个更有趣的数字猜谜游戏

void guess_number_with_clues(int number, auto message)
{
    std::cout << "Guess the number.\n";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;
        }
        std::cout << guess.value() << " is wrong. Try again\n";
        std::cout << message(number, guess.value());          ?
    }
    std::cout <<
        std::format("The number was {:0>5}\n", (number));     ?
}
 
int main()
{
    check_properties();                                       ?
    auto message = [](int number, int guess) {
        return std::format("{}\n",
            check_which_digits_correct(number, guess));
    };                                                        ?
    guess_number_with_clues(some_prime_number(), message);    ?
}

? 展示提示

显示的数字正确为五位数

? 进行测试调用

? 消息指出哪些数字是正确的

进行游戏

如果我们玩这个游戏,可以从两个不同数字的质数开始,以缩小可能的数字范围。12347 和 56809 涵盖了所有数字,因此它们是很好的起始猜测(图 3.7)。

3.3.5 通过 std::function 提供多种线索

现在,90113 在图 3.7 中被猜测为不是质数。我们可以很容易地将这个检查添加到我们的信息中。

列表 3.16 一条更长的消息

auto get_message = [](int number, int guess) {
    return std::format("{}\n{}\n",
        is_prime(guess) ? "Prime" : "Not prime",      ?
        check_which_digits_correct(number, guess));   ?
};
 
guess_number_with_clues(some_prime_number(), get_message);

这个猜测是一个质数吗?

哪些数字是正确的呢?

我们可以进一步扩展这个,但在单个 lambda 中添加很多独立的检查并不是一个好主意。Lambda 在我们需要一个小函数时非常有效,但我们不应该让它们变得复杂。我们需要一种不同的方法。我们还可以添加一个长度检查,因为数字不会超过五位。我们因此尝试检查三件事,并在每种情况下返回一条消息。我们可以用两个独立的 lambda 来检查长度和一个数字是否为质数,获取猜测并返回一个字符串。

列表 3.17 检查数字的长度及其是否为质数

auto check_prime = [](int guess) {
    return std::string((is_prime(guess)) ? "" : "Not prime\n");
};
 
auto check_length = [](int guess) {
    return std::string((guess < 100000) ? "" : "Too long\n");
};

列表 3.14 提供了哪些数字是正确的线索,但它需要一个数字和一个猜测。我们可以利用闭包的特性,创建一个匿名函数来接受一个整数,只需捕获要猜测的数字。在第 2.3 节中,我们首次接触到 lambda 时,看到 [=] 和 [&] 分别表示按值和按引用捕获。我们可以使用 [number] 来表示按值捕获变量 number ,因为在按值捕获特定变量时不需要使用 = 符号。我们也可以使用 [&number] 来表示按引用捕获 number 。无论哪种方式,我们都封装了一个函数,接受两个数字和要猜测的数字,以创建一个新函数。

第 3.18 节 捕获数字

int number = some_prime_number();
auto check_digits = [number](int guess) {     ?
    return std::format("{}\n", 
        check_which_digits_correct(number, guess));
};

通过复制来获取数字

现在我们有三个 lambda 函数,它们接受一个整数并返回一个字符串。将它们放入一个容器中,比如一个 vector ,这样游戏就可以逐步检查线索并可能添加更多的检查。这种情况下, vector 会包含什么类型呢?我们知道每个 lambda 的类型都不同,但我们可以将它们强行放入一个 std::function 中,并放入一个容器,只要我们包含 functional 和 vector 的头部。猜谜游戏可以检查线索并只显示第一个。如果我们先检查这个数字是否是质数,就可以强制要求猜测的数字是质数,并在做出另一个猜测之前避免给出更多线索。因此,我们需要对猜测函数进行一些小的修改,以调用消息。

列表 3.19 利用所有线索

void guess_number_with_more_clues(int number, auto messages)
{
    std::cout << "Guess the number.\n>";
    std::optional<int> guess;
    while (guess = read_number(std::cin))
    {
        if (guess.value() == number)
        {
            std::cout << "Well done.";
            return;
        }
        std::cout << std::format("{:0>5} is wrong. Try again\n",
                                 guess.value());
        for (auto message : messages)            ?
        {
            auto clue = message(guess.value());
            if (clue.length())                   ?
            {                                    ?
                std::cout << clue;               ?
                break;                           ?
            }
        }
    }
    std::cout << std::format("The number was {:0>5}\n", (number));
}

接收消息

只显示第一个提示

现在我们可以在 main 函数中调用测试代码后再调用我们的游戏。

列表 3.20 整合所有内容

int main()
{
    check_properties();
    auto check_prime = [](int guess) {
        return std::string((is_prime(guess)) ? "" : "Not prime\n");
    };
 
    auto check_length = [](int guess) {
        return std::string((guess < 100000) ? "" : "Too long\n");
    };
 
    const int number = some_prime_number();
    auto check_digits = [number](int guess) {
        return std::format("{}\n",
             check_which_digits_correct(number, guess));    
    };
    std::vector<
        std::function<std::string(int)>
    > messages                                         ?
    {
        check_length,
        check_prime,
        check_digits 
    };
    guess_number_with_more_clues(number, messages);    ?
}

? 整理检查和线索

进行游戏

拥有一些素数作为起点会让游戏变得更简单。可以尝试 12347 和 56809,因为它们包含了所有的数字。我们可以忽略任何提示,因此可以先找出需要的五个数字。

将 lambda 转换为 std::function 并不是一个理想的选择,正如我们在第 3.1.3 节中所看到的,因为它无法再进行内联。我们将在最后一章学习模板的参数包时探讨另一种方法。目前,我们已经学习了输入和输出,以及字符串、整数和向量。我们还可以生成随机数。接下来,我们将学习如何处理时间,并继续提升我们的 C++ 知识。

概要

  • 字符输入来自 std::cin ,可以流式传输到特定类型,但我们需要检查错误并清理未使用的输入。
  • 使用 std::getline 可以获取整行文本,包括空白字符。
  • std::optional 可以用于可能未定义的值。
  • std::cin 和 std::optional 都拥有一个 explicit operator bool ,这让我们可以轻松检查错误或缺失的值。
  • 寻找语言和库中的共通模式,以便更好地指导自己的代码。
  • 在 C++中生成随机数需要同时使用引擎和分布。
  • 一个随机数生成器可以用 std::random_device 进行种子设置。
  • 拒绝采样是一种快速选择随机数的方法,能够满足特定属性,适用于没有合适分布的情况。
  • 一些表达式可以在编译时进行计算,因此用 constexpr 来标记它们是个不错的主意。
  • 使用 static_assert 来检查编译时的表达式。
  • 一个 lambda 可以存储在 std::function 中,但这可能会导致代码变得更大且运行更慢。

最近发表
标签列表